Registry rows already carry the URL and auth indicators, so clicking Add now registers in place: the card flips to a View link into the integration's hub, the user stays on the picker, and several adds can run at once. Each plugin exposes a headless useQuickAdd on its client plugin — MCP probes and declares the detected methods (sharing the add page's seed policy via mcpDetectedAuthSeeds), OpenAPI lets the spec carry auth, GraphQL declares the registry's header pattern. A quick add that cannot complete falls back to the configuration screen prefilled with the same facts. Also fixes two latent bugs this surfaced: the probe API response schema stripped versionNegotiation, so the legacy-protocol pin never survived the HTTP boundary; and per-row state keyed on domain|kind, which conflated two same-kind products on one domain (Google Photos Library and Picker).
Unified · Split
| @@ -68,16 +68,30 @@ scenario( · 67 unmodified lines | |||
| 68 | await cards.filter({ hasText: "Google Photos Picker API" }).waitFor(); | 68 | await cards.filter({ hasText: "Google Photos Picker API" }).waitFor(); |
| 69 | }); | 69 | }); |
| 70 | 70 | ||
| 71 | await step("Add Photos Library with its own registry URL and namespace", async () => { | 71 | await step("Add Photos Library in place; the Picker card is untouched", async () => { |
| 72 | await page | 72 | await page |
| 73 | .getByTestId("catalog-photos.google.com-openapi") | 73 | .getByTestId("catalog-photos.google.com-openapi") |
| 74 | .filter({ hasText: "Google Photos Library API" }) | 74 | .filter({ hasText: "Google Photos Library API" }) |
| 75 | .getByRole("button", { name: "Add Google Photos Library API" }) | 75 | .getByRole("button", { name: "Add Google Photos Library API" }) |
| 76 | .click(); | 76 | .click(); |
| 77 | await page.waitForURL(/\/integrations\/add\/openapi/); | 77 | const libraryCard = page |
| 78 | const url = new URL(page.url()); | 78 | .getByTestId("catalog-photos.google.com-openapi") |
| 79 | expect(url.searchParams.get("url")).toBe(librarySpecUrl); | 79 | .filter({ hasText: "Google Photos Library API" }); |
| 80 | expect(url.searchParams.get("namespace")).toBe("google-photos-library"); | 80 | await libraryCard |
| 81 | .getByRole("link", { name: "View Google Photos Library API" }) | ||
| 82 | .waitFor({ timeout: 120_000 }); | ||
| 83 | expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | ||
| 84 | // Product identity survived: the LIBRARY slug became the namespace… | ||
| 85 | const href = await libraryCard | ||
| 86 | .getByRole("link", { name: "View Google Photos Library API" }) | ||
| 87 | .getAttribute("href"); | ||
| 88 | expect(href).toContain("/integrations/google_photos_library"); | ||
| 89 | // …and the Picker card, same domain and kind, still offers Add. | ||
| 90 | await page | ||
| 91 | .getByTestId("catalog-photos.google.com-openapi") | ||
| 92 | .filter({ hasText: "Google Photos Picker API" }) | ||
| 93 | .getByRole("button", { name: "Add Google Photos Picker API" }) | ||
| 94 | .waitFor(); | ||
| 81 | }); | 95 | }); |
| 82 | }); | 96 | }); |
| 83 | }), | 97 | }), |
| @@ -1,24 +1,35 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | |||
| 1 | import { expect } from "@effect/vitest"; | 3 | import { expect } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 4 | import { Effect } from "effect"; |
| 5 | import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | ||
| 3 | 6 | ||
| 4 | import { scenario } from "../src/scenario"; | 7 | import { scenario } from "../src/scenario"; |
| 5 | import { Browser, Target } from "../src/services"; | 8 | import { Browser, Target } from "../src/services"; |
| 6 | import { visit } from "../src/surfaces/browser"; | 9 | import { visit } from "../src/surfaces/browser"; |
| 7 | 10 | ||
| 8 | // The picker's registry search goes to the public integrations.sh service from | 11 | // The picker's registry search goes to the public integrations.sh service from |
| 9 | // the browser. CI must not depend on the live service, so both registry | 12 | // the browser. CI must not depend on the live service, so the search endpoint |
| 10 | // endpoints are fulfilled at the network layer here — including the CORS header | 13 | // is fulfilled at the network layer here — including the CORS header a real |
| 11 | // a real cross-origin browser call needs. | 14 | // cross-origin browser call needs. The MCP endpoints themselves are NOT |
| 15 | // stubbed: the quick add probes and registers server-side, so the working row | ||
| 16 | // points at a local fixture server and the broken row at a reserved-TLD host. | ||
| 12 | scenario( | 17 | scenario( |
| 13 | "Add page: integrations.sh catalog search resolves into a prefilled add flow", | 18 | "Add page: a registry row adds in place, and falls back to the config screen", |
| 14 | {}, | 19 | {}, |
| 15 | Effect.gen(function* () { | 20 | Effect.gen(function* () { |
| 16 | const target = yield* Target; | 21 | const target = yield* Target; |
| 17 | const browser = yield* Browser; | 22 | const browser = yield* Browser; |
| 23 | // Unique per run — the derived namespace must not collide on targets | ||
| 24 | // whose identities share one tenant (selfhost admin). | ||
| 25 | const suffix = randomBytes(3).toString("hex"); | ||
| 26 | const server = yield* serveMcpServer(() => | ||
| 27 | makeGreetingMcpServer({ name: `catalog-quick-${suffix}` }), | ||
| 28 | ); | ||
| 18 | const identity = yield* target.newIdentity(); | 29 | const identity = yield* target.newIdentity(); |
| 19 | 30 | ||
| 20 | yield* browser.session(identity, async ({ page, step }) => { | 31 | yield* browser.session(identity, async ({ page, step }) => { |
| 21 | await step("Stub the integrations.sh registry endpoints", async () => { | 32 | await step("Stub the integrations.sh search endpoint", async () => { |
| 22 | await page.route("https://integrations.sh/api/search*", (route) => | 33 | await page.route("https://integrations.sh/api/search*", (route) => |
| 23 | route.fulfill({ | 34 | route.fulfill({ |
| 24 | contentType: "application/json", | 35 | contentType: "application/json", |
| @@ -30,44 +41,73 @@ scenario( · 5 unmodified lines | |||
| 30 | name: "todoist.com", | 41 | name: "todoist.com", |
| 31 | description: "Tasks, projects, and collaboration.", | 42 | description: "Tasks, projects, and collaboration.", |
| 32 | kinds: ["mcp", "cli"], | 43 | kinds: ["mcp", "cli"], |
| 33 | url: "https://integrations.sh/todoist.com/", | 44 | surfaces: [ |
| 45 | { kind: "mcp", slug: `todoist-${suffix}`, url: server.endpoint }, | ||
| 46 | { kind: "cli", slug: "todoist-cli" }, | ||
| 47 | ], | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | domain: "deadserver.com", | ||
| 51 | name: "deadserver.com", | ||
| 52 | description: "A vendor whose MCP endpoint is gone.", | ||
| 53 | kinds: ["mcp"], | ||
| 54 | surfaces: [ | ||
| 55 | { kind: "mcp", slug: "deadserver", url: "https://mcp.notareal.invalid/mcp" }, | ||
| 56 | ], | ||
| 34 | }, | 57 | }, |
| 35 | ], | ||
| 36 | }, | ||
| 37 | }), | ||
| 38 | ); | ||
| 39 | await page.route("https://integrations.sh/api/todoist.com/surface", (route) => | ||
| 40 | route.fulfill({ | ||
| 41 | contentType: "application/json", | ||
| 42 | headers: { "access-control-allow-origin": "*" }, | ||
| 43 | json: { | ||
| 44 | version: 3, | ||
| 45 | domain: "todoist.com", | ||
| 46 | surfaces: [ | ||
| 47 | { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" }, | ||
| 48 | { type: "cli", slug: "todoist-cli" }, | ||
| 49 | ], | 58 | ], |
| 50 | }, | 59 | }, |
| 51 | }), | 60 | }), |
| 52 | ); | 61 | ); |
| 53 | }); | 62 | }); |
| 54 | 63 | ||
| 55 | await step("Searching surfaces the catalog row under the presets", async () => { | 64 | await step("Searching surfaces the connectable rows, not the CLI", async () => { |
| 56 | await visit(page, "/integrations/browse"); | 65 | await visit(page, "/integrations/browse"); |
| 57 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); | 66 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); |
| 58 | // The CLI-only surface is not offered; the connectable kind is. | 67 | await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist MCP").waitFor(); |
| 59 | await page.getByTestId("catalog-todoist.com-mcp").getByText("MCP").waitFor(); | 68 | await page.getByTestId("catalog-deadserver").waitFor(); |
| 69 | expect(await page.getByTestId("catalog-todoist-cli").count()).toBe(0); | ||
| 60 | }); | 70 | }); |
| 61 | 71 | ||
| 62 | await step("Adding from the row lands on the MCP add flow, prefilled", async () => { | 72 | await step("Add registers in place; the card flips to View", async () => { |
| 63 | await page | 73 | await page |
| 64 | .getByTestId("catalog-todoist.com-mcp") | 74 | .getByTestId(`catalog-todoist-${suffix}`) |
| 65 | .getByRole("button", { name: "Add Todoist MCP" }) | 75 | .getByRole("button", { name: "Add Todoist MCP" }) |
| 66 | .click(); | 76 | .click(); |
| 67 | await page.waitForURL(/\/integrations\/add\/mcp/); | 77 | // Generous timeout: the quick add probes the fixture server and |
| 78 | // registers, all behind one click, and the dev server can queue | ||
| 79 | // under CI load. | ||
| 80 | await page | ||
| 81 | .getByTestId(`catalog-todoist-${suffix}`) | ||
| 82 | .getByRole("link", { name: "View Todoist MCP" }) | ||
| 83 | .waitFor({ timeout: 90_000 }); | ||
| 84 | // The whole point: the user never left the picker. | ||
| 85 | expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | ||
| 86 | }); | ||
| 87 | |||
| 88 | await step("View jumps to the integration's hub", async () => { | ||
| 89 | await page | ||
| 90 | .getByTestId(`catalog-todoist-${suffix}`) | ||
| 91 | .getByRole("link", { name: "View Todoist MCP" }) | ||
| 92 | .click(); | ||
| 93 | await page.waitForURL(new RegExp(`/integrations/todoist_${suffix}`)); | ||
| 94 | await page.goBack(); | ||
| 95 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); | ||
| 96 | await page.getByTestId("catalog-deadserver").waitFor(); | ||
| 97 | }); | ||
| 98 | |||
| 99 | await step("An unreachable endpoint falls back to the config screen", async () => { | ||
| 100 | await page | ||
| 101 | .getByTestId("catalog-deadserver") | ||
| 102 | .getByRole("button", { name: "Add Deadserver MCP" }) | ||
| 103 | .click(); | ||
| 104 | // Quick add cannot register it, so the click lands on the add flow | ||
| 105 | // prefilled with the same facts — where the probe failure renders | ||
| 106 | // with retry UX. | ||
| 107 | await page.waitForURL(/\/integrations\/add\/mcp/, { timeout: 90_000 }); | ||
| 68 | const url = new URL(page.url()); | 108 | const url = new URL(page.url()); |
| 69 | expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp"); | 109 | expect(url.searchParams.get("url")).toBe("https://mcp.notareal.invalid/mcp"); |
| 70 | expect(url.searchParams.get("namespace")).toBe("todoist"); | 110 | expect(url.searchParams.get("namespace")).toBe("deadserver"); |
| 71 | }); | 111 | }); |
| 72 | }); | 112 | }); |
| 73 | }), | 113 | }), |
| @@ -78,15 +78,25 @@ scenario( · 77 unmodified lines | |||
| 78 | await card.getByText("gmail.com").waitFor(); | 78 | await card.getByText("gmail.com").waitFor(); |
| 79 | }); | 79 | }); |
| 80 | 80 | ||
| 81 | await step("Add Gmail with its registry URL and namespace prefilled", async () => { | 81 | await step("Add Gmail in place: the card flips to View", async () => { |
| 82 | await page | 82 | await page |
| 83 | .getByTestId("catalog-gmail.com-openapi") | 83 | .getByTestId("catalog-gmail.com-openapi") |
| 84 | .getByRole("button", { name: "Add Gmail API" }) | 84 | .getByRole("button", { name: "Add Gmail API" }) |
| 85 | .click(); | 85 | .click(); |
| 86 | await page.waitForURL(/\/integrations\/add\/openapi/); | 86 | // The quick add fetches the hosted spec server-side and registers — |
| 87 | const url = new URL(page.url()); | 87 | // no navigation. Generous timeout: a real spec fetch + parse rides |
| 88 | expect(url.searchParams.get("url")).toBe(gmailSpecUrl); | 88 | // behind the click. |
| 89 | expect(url.searchParams.get("namespace")).toBe("google-gmail"); | 89 | await page |
| 90 | .getByTestId("catalog-gmail.com-openapi") | ||
| 91 | .getByRole("link", { name: "View Gmail API" }) | ||
| 92 | .waitFor({ timeout: 120_000 }); | ||
| 93 | expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/); | ||
| 94 | // The registry surface slug became the namespace. | ||
| 95 | const href = await page | ||
| 96 | .getByTestId("catalog-gmail.com-openapi") | ||
| 97 | .getByRole("link", { name: "View Gmail API" }) | ||
| 98 | .getAttribute("href"); | ||
| 99 | expect(href).toContain("/integrations/google_gmail"); | ||
| 90 | }); | 100 | }); |
| 91 | 101 | ||
| 92 | await step("Search for Outlook and see its separate registry card and domain", async () => { | 102 | await step("Search for Outlook and see its separate registry card and domain", async () => { |
| @@ -195,6 +195,30 @@ export interface EditSheetSectionProps { · 194 unmodified lines | |||
| 195 | readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void; | 195 | readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void; |
| 196 | } | 196 | } |
| 197 | 197 | ||
| 198 | /** What a registry row already knows about a connect target — enough, for | ||
| 199 | * some plugins, to register without showing the configuration screen. */ | ||
| 200 | export interface IntegrationQuickAddInput { | ||
| 201 | /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */ | ||
| 202 | readonly url: string; | ||
| 203 | /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */ | ||
| 204 | readonly name: string; | ||
| 205 | /** Registry surface slug, used as the namespace seed when present. */ | ||
| 206 | readonly slug?: string; | ||
| 207 | /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */ | ||
| 208 | readonly authHeader?: string; | ||
| 209 | /** Registry-declared credential kind ("none", "oauth", "api_key", …). */ | ||
| 210 | readonly authKind?: string; | ||
| 211 | /** RFC 6902 patch the registry says to apply to the fetched spec. */ | ||
| 212 | readonly specOverrides?: readonly unknown[]; | ||
| 213 | } | ||
| 214 | |||
| 215 | export type IntegrationQuickAddResult = | ||
| 216 | /** Registered; `slug` is the created integration's namespace. */ | ||
| 217 | | { readonly ok: true; readonly slug: string } | ||
| 218 | /** Could not add headlessly — the host falls back to the configuration | ||
| 219 | * screen, which renders the failure with full context. */ | ||
| 220 | | { readonly ok: false; readonly reason: string }; | ||
| 221 | |||
| 198 | export interface IntegrationPlugin { | 222 | export interface IntegrationPlugin { |
| 199 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ | 223 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ |
| 200 | readonly key: string; | 224 | readonly key: string; |
| @@ -256,6 +280,17 @@ export interface IntegrationPlugin { · 55 unmodified lines | |||
| 256 | * Call from the host on intent (hover/focus) so the chunks land before the | 280 | * Call from the host on intent (hover/focus) so the chunks land before the |
| 257 | * user navigates into the add page. Idempotent. */ | 281 | * user navigates into the add page. Idempotent. */ |
| 258 | readonly preload?: () => void; | 282 | readonly preload?: () => void; |
| 283 | /** Headless one-click add for a registry row whose facts are already known | ||
| 284 | * (URL + auth indicators). A HOOK, not a plain function, because each | ||
| 285 | * plugin binds its own mutation atoms — the host mounts one bridge | ||
| 286 | * component per plugin to collect the bound callbacks. Absent means the | ||
| 287 | * plugin always needs its configuration screen. A `{ok: false}` result is | ||
| 288 | * an expected outcome (unreachable server, slug collision), not an error: | ||
| 289 | * the host falls back to the configuration screen prefilled with the same | ||
| 290 | * facts. */ | ||
| 291 | readonly useQuickAdd?: () => ( | ||
| 292 | input: IntegrationQuickAddInput, | ||
| 293 | ) => Promise<IntegrationQuickAddResult>; | ||
| 259 | } | 294 | } |
| 260 | 295 | ||
| 261 | // --------------------------------------------------------------------------- | 296 | // --------------------------------------------------------------------------- |
| @@ -1,6 +1,49 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy, useCallback } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import type { | ||
| 5 | IntegrationPlugin, | ||
| 6 | IntegrationQuickAddInput, | ||
| 7 | IntegrationQuickAddResult, | ||
| 8 | } from "@executor-js/sdk/client"; | ||
| 9 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 10 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | ||
| 11 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 3 | import { graphqlPresets } from "../sdk/presets"; | 12 | import { graphqlPresets } from "../sdk/presets"; |
| 13 | import { createGraphqlIntegrationOptimistic } from "./atoms"; | ||
| 14 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; | ||
| 15 | |||
| 16 | /** One-click add for a registry GraphQL row. A GraphQL endpoint cannot | ||
| 17 | * describe its own auth, so the registry's declared header pattern is the | ||
| 18 | * only carrier of facts like Linear's no-Bearer key — it becomes the | ||
| 19 | * declared method, exactly as the add page would seed it. */ | ||
| 20 | function useGraphqlQuickAdd(): ( | ||
| 21 | input: IntegrationQuickAddInput, | ||
| 22 | ) => Promise<IntegrationQuickAddResult> { | ||
| 23 | const doAdd = useAtomSet(createGraphqlIntegrationOptimistic, { mode: "promiseExit" }); | ||
| 24 | return useCallback( | ||
| 25 | async (input) => { | ||
| 26 | const slug = slugifyNamespace(input.slug ?? input.name); | ||
| 27 | if (!slug) return { ok: false, reason: "no derivable slug" }; | ||
| 28 | const placement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null; | ||
| 29 | const authenticationTemplate = placement | ||
| 30 | ? graphqlAuthMethodInputsFromPlacements([placement]) | ||
| 31 | : []; | ||
| 32 | const exit = await doAdd({ | ||
| 33 | payload: { | ||
| 34 | endpoint: input.url, | ||
| 35 | slug, | ||
| 36 | name: input.name, | ||
| 37 | ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), | ||
| 38 | }, | ||
| 39 | reactivityKeys: integrationWriteKeys, | ||
| 40 | }); | ||
| 41 | if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | ||
| 42 | return { ok: true, slug: String(exit.value.slug) }; | ||
| 43 | }, | ||
| 44 | [doAdd], | ||
| 45 | ); | ||
| 46 | } | ||
| 4 | 47 | ||
| 5 | const importAdd = () => import("./AddGraphqlIntegration"); | 48 | const importAdd = () => import("./AddGraphqlIntegration"); |
| 6 | const importAccounts = () => import("./GraphqlAccountsPanel"); | 49 | const importAccounts = () => import("./GraphqlAccountsPanel"); |
| @@ -17,4 +60,5 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 10 unmodified lines | |||
| 17 | void importAdd(); | 60 | void importAdd(); |
| 18 | void importAccounts(); | 61 | void importAccounts(); |
| 19 | }, | 62 | }, |
| 63 | useQuickAdd: useGraphqlQuickAdd, | ||
| 20 | }; | 64 | }; |
| @@ -85,6 +85,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 84 unmodified lines | |||
| 85 | serverName: Schema.NullOr(Schema.String), | 85 | serverName: Schema.NullOr(Schema.String), |
| 86 | /** Server `instructions` from initialize — prefills the description field. */ | 86 | /** Server `instructions` from initialize — prefills the description field. */ |
| 87 | instructions: Schema.NullOr(Schema.String), | 87 | instructions: Schema.NullOr(Schema.String), |
| 88 | /** Which protocol negotiation worked, when discovery succeeded. `legacy` | ||
| 89 | * means the server echoes the modern revision but breaks its contract, and | ||
| 90 | * the add must pin `versionNegotiation: "legacy"` on the integration — | ||
| 91 | * omitting this field here silently stripped it from the HTTP response and | ||
| 92 | * the pin never happened. */ | ||
| 93 | versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), | ||
| 88 | }); | 94 | }); |
| 89 | 95 | ||
| 90 | // --------------------------------------------------------------------------- | 96 | // --------------------------------------------------------------------------- |
| @@ -41,7 +41,11 @@ import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placemen · 40 unmodified lines | |||
| 41 | import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; | 41 | import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; |
| 42 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; | 42 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 43 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; | 43 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; |
| 44 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; | 44 | import { |
| 45 | mcpAuthMethodInputFromEditorValue, | ||
| 46 | mcpDetectedAuthSeeds, | ||
| 47 | mcpWireAuthInput, | ||
| 48 | } from "./auth-method-config"; | ||
| 45 | import { parseStdioArgs } from "./stdio-fields"; | 49 | import { parseStdioArgs } from "./stdio-fields"; |
| 46 | import { isProbableMcpEndpoint } from "./probe-url"; | 50 | import { isProbableMcpEndpoint } from "./probe-url"; |
| 47 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; | 51 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; |
| @@ -219,46 +223,16 @@ export default function AddMcpIntegration(props: { · 171 unmodified lines | |||
| 219 | // The probe seeds the method list: detected OAuth → an OAuth row; a 401 | 223 | // The probe seeds the method list: detected OAuth → an OAuth row; a 401 |
| 220 | // without OAuth metadata → a bearer-header row; an open server → a no-auth | 224 | // without OAuth metadata → a bearer-header row; an open server → a no-auth |
| 221 | // row. The user can edit any row or add alternate methods alongside. | 225 | // row. The user can edit any row or add alternate methods alongside. |
| 222 | const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { | 226 | const authMethodSeeds: readonly AuthMethodSeed[] = useMemo( |
| 223 | const registryPlacement = props.initialAuthHeader | 227 | () => |
| 224 | ? placementFromHeaderPattern(props.initialAuthHeader) | 228 | mcpDetectedAuthSeeds(probe, { |
| 225 | : null; | 229 | placement: props.initialAuthHeader |
| 226 | if (!probe) { | 230 | ? placementFromHeaderPattern(props.initialAuthHeader) |
| 227 | // No probe result (pending, or the server was unreachable from here). | 231 | : null, |
| 228 | // The registry's declared facts still stand: an authless server or a | 232 | kind: props.initialAuthKind, |
| 229 | // known header pattern seeds the list the probe would have produced. | 233 | }), |
| 230 | if (registryPlacement) | 234 | [probe, props.initialAuthHeader, props.initialAuthKind], |
| 231 | return [{ value: { kind: "apikey", placements: [registryPlacement] } }]; | 235 | ); |
| 232 | if (props.initialAuthKind === "none") return [{ value: { kind: "none" } }]; | ||
| 233 | return []; | ||
| 234 | } | ||
| 235 | if (probe.requiresOAuth) { | ||
| 236 | const oauth: AuthMethodSeed = { | ||
| 237 | value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, | ||
| 238 | label: "Detected", | ||
| 239 | }; | ||
| 240 | // GitHub's MCP server takes a PAT bearer header in clients without | ||
| 241 | // OAuth; when the registry declared that placement, offer it alongside. | ||
| 242 | return registryPlacement | ||
| 243 | ? [oauth, { value: { kind: "apikey", placements: [registryPlacement] } }] | ||
| 244 | : [oauth]; | ||
| 245 | } | ||
| 246 | if (probe.requiresAuthentication) { | ||
| 247 | // The registry's exact placement beats the generic Bearer guess. | ||
| 248 | return [ | ||
| 249 | { | ||
| 250 | value: { | ||
| 251 | kind: "apikey", | ||
| 252 | placements: [ | ||
| 253 | registryPlacement ?? { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 254 | ], | ||
| 255 | }, | ||
| 256 | label: "Detected", | ||
| 257 | }, | ||
| 258 | ]; | ||
| 259 | } | ||
| 260 | return [{ value: { kind: "none" }, label: "Detected" }]; | ||
| 261 | }, [probe, props.initialAuthHeader, props.initialAuthKind]); | ||
| 262 | const authMethodList = useAuthMethodList(authMethodSeeds); | 236 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 263 | 237 | ||
| 264 | const remoteIdentity = useIntegrationIdentity({ | 238 | const remoteIdentity = useIntegrationIdentity({ |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | 11 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 12 | import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor"; | ||
| 12 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | 13 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 13 | import { | 14 | import { |
| 14 | authMethodFromSharedTemplate, | 15 | authMethodFromSharedTemplate, |
| @@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines | |||
| 126 | if (wire.length === 0) return []; | 127 | if (wire.length === 0) return []; |
| 127 | return [{ kind: "apikey", placements: wire }]; | 128 | return [{ kind: "apikey", placements: wire }]; |
| 128 | } | 129 | } |
| 130 | |||
| 131 | /** The auth methods a registry-driven MCP add declares before any editing: | ||
| 132 | * the probe's detection plus the registry's declared facts. ONE policy for | ||
| 133 | * both the add page (as editor seeds) and the one-click quick add (mapped | ||
| 134 | * straight to wire inputs) — two copies drifted is how the picker and the | ||
| 135 | * add page end up declaring different methods for the same server. */ | ||
| 136 | export function mcpDetectedAuthSeeds( | ||
| 137 | probe: { | ||
| 138 | readonly requiresOAuth: boolean; | ||
| 139 | readonly requiresAuthentication: boolean; | ||
| 140 | } | null, | ||
| 141 | registry: { | ||
| 142 | readonly placement?: Placement | null; | ||
| 143 | readonly kind?: string | undefined; | ||
| 144 | }, | ||
| 145 | ): readonly AuthMethodSeed[] { | ||
| 146 | const registryPlacement = registry.placement ?? null; | ||
| 147 | if (!probe) { | ||
| 148 | // No probe result (pending, or the server was unreachable from here). | ||
| 149 | // The registry's declared facts still stand: an authless server or a | ||
| 150 | // known header pattern seeds the list the probe would have produced. | ||
| 151 | if (registryPlacement) return [{ value: { kind: "apikey", placements: [registryPlacement] } }]; | ||
| 152 | if (registry.kind === "none") return [{ value: { kind: "none" } }]; | ||
| 153 | return []; | ||
| 154 | } | ||
| 155 | if (probe.requiresOAuth) { | ||
| 156 | const oauth: AuthMethodSeed = { | ||
| 157 | value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }, | ||
| 158 | label: "Detected", | ||
| 159 | }; | ||
| 160 | // GitHub's MCP server takes a PAT bearer header in clients without | ||
| 161 | // OAuth; when the registry declared that placement, offer it alongside. | ||
| 162 | return registryPlacement | ||
| 163 | ? [oauth, { value: { kind: "apikey", placements: [registryPlacement] } }] | ||
| 164 | : [oauth]; | ||
| 165 | } | ||
| 166 | if (probe.requiresAuthentication) { | ||
| 167 | // The registry's exact placement beats the generic Bearer guess. | ||
| 168 | return [ | ||
| 169 | { | ||
| 170 | value: { | ||
| 171 | kind: "apikey", | ||
| 172 | placements: [ | ||
| 173 | registryPlacement ?? { carrier: "header", name: "Authorization", prefix: "Bearer " }, | ||
| 174 | ], | ||
| 175 | }, | ||
| 176 | label: "Detected", | ||
| 177 | }, | ||
| 178 | ]; | ||
| 179 | } | ||
| 180 | return [{ value: { kind: "none" }, label: "Detected" }]; | ||
| 181 | } | ||
| @@ -1,6 +1,21 @@ | |||
| 1 | import { lazy, type ComponentProps, type ComponentType } from "react"; | 1 | import { lazy, useCallback, type ComponentProps, type ComponentType } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import type { | ||
| 5 | IntegrationPlugin, | ||
| 6 | IntegrationQuickAddInput, | ||
| 7 | IntegrationQuickAddResult, | ||
| 8 | } from "@executor-js/sdk/client"; | ||
| 9 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 10 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | ||
| 11 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 3 | import { mcpPresets } from "../sdk/presets"; | 12 | import { mcpPresets } from "../sdk/presets"; |
| 13 | import { addMcpServer, probeMcpEndpoint } from "./atoms"; | ||
| 14 | import { | ||
| 15 | mcpAuthMethodInputFromEditorValue, | ||
| 16 | mcpDetectedAuthSeeds, | ||
| 17 | mcpWireAuthInput, | ||
| 18 | } from "./auth-method-config"; | ||
| 4 | 19 | ||
| 5 | const importAdd = () => import("./AddMcpIntegration"); | 20 | const importAdd = () => import("./AddMcpIntegration"); |
| 6 | const importEditSheet = () => import("./EditMcpIntegration"); | 21 | const importEditSheet = () => import("./EditMcpIntegration"); |
| @@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines | |||
| 12 | 27 | ||
| 13 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; | 28 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; |
| 14 | 29 | ||
| 30 | /** One-click add for a registry MCP row: probe, declare the auth methods the | ||
| 31 | * add page would have seeded (same policy — `mcpDetectedAuthSeeds`), and | ||
| 32 | * register. A failed probe means the configuration screen's job — it renders | ||
| 33 | * the failure with retry UX — so quick add reports `{ok: false}` instead of | ||
| 34 | * guessing. */ | ||
| 35 | function useMcpQuickAdd(): (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult> { | ||
| 36 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); | ||
| 37 | const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); | ||
| 38 | return useCallback( | ||
| 39 | async (input) => { | ||
| 40 | const probeExit = await doProbe({ payload: { endpoint: input.url } }); | ||
| 41 | if (Exit.isFailure(probeExit)) return { ok: false, reason: "probe failed" }; | ||
| 42 | const probe = probeExit.value; | ||
| 43 | const seeds = mcpDetectedAuthSeeds(probe, { | ||
| 44 | placement: input.authHeader ? placementFromHeaderPattern(input.authHeader) : null, | ||
| 45 | kind: input.authKind, | ||
| 46 | }); | ||
| 47 | const methods = seeds.map((seed) => | ||
| 48 | mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(seed.value)), | ||
| 49 | ); | ||
| 50 | const slug = input.slug ? slugifyNamespace(input.slug) : undefined; | ||
| 51 | const addExit = await doAddServer({ | ||
| 52 | payload: { | ||
| 53 | transport: "remote" as const, | ||
| 54 | name: input.name, | ||
| 55 | endpoint: input.url, | ||
| 56 | ...(slug ? { slug } : {}), | ||
| 57 | ...(probe.instructions ? { description: probe.instructions } : {}), | ||
| 58 | authenticationTemplate: methods.length > 0 ? methods : [{ kind: "none" as const }], | ||
| 59 | // The probe reports when only legacy negotiation worked (a server | ||
| 60 | // that echoes the modern revision but breaks its contract); pin it | ||
| 61 | // so refreshes and tool calls use the same handshake. | ||
| 62 | ...(probe.versionNegotiation === "legacy" | ||
| 63 | ? { versionNegotiation: "legacy" as const } | ||
| 64 | : {}), | ||
| 65 | }, | ||
| 66 | reactivityKeys: integrationWriteKeys, | ||
| 67 | }); | ||
| 68 | if (Exit.isFailure(addExit)) return { ok: false, reason: "add failed" }; | ||
| 69 | return { ok: true, slug: String(addExit.value.slug) }; | ||
| 70 | }, | ||
| 71 | [doProbe, doAddServer], | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | |||
| 15 | export interface McpIntegrationPluginOptions { | 75 | export interface McpIntegrationPluginOptions { |
| 16 | /** | 76 | /** |
| 17 | * Enable the stdio transport in the add-integration UI (tab + presets). | 77 | * Enable the stdio transport in the add-integration UI (tab + presets). |
| @@ -50,6 +110,7 @@ export const createMcpIntegrationPlugin = ( · 32 unmodified lines | |||
| 50 | void importEditSheet(); | 110 | void importEditSheet(); |
| 51 | void importAccounts(); | 111 | void importAccounts(); |
| 52 | }, | 112 | }, |
| 113 | useQuickAdd: useMcpQuickAdd, | ||
| 53 | }; | 114 | }; |
| 54 | }; | 115 | }; |
| 55 | 116 | ||
| @@ -1,6 +1,54 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy, useCallback } from "react"; |
| 2 | import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import type { | ||
| 5 | IntegrationPlugin, | ||
| 6 | IntegrationPreset, | ||
| 7 | IntegrationQuickAddInput, | ||
| 8 | IntegrationQuickAddResult, | ||
| 9 | } from "@executor-js/sdk/client"; | ||
| 10 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | ||
| 11 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 3 | import { openApiPresets } from "../sdk/presets"; | 12 | import { openApiPresets } from "../sdk/presets"; |
| 13 | import { addOpenApiSpec } from "./atoms"; | ||
| 14 | import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; | ||
| 15 | |||
| 16 | /** One-click add for a registry OpenAPI row. The spec itself is the | ||
| 17 | * configuration: omitting `authenticationTemplate` and `baseUrl` tells the | ||
| 18 | * server to derive both from the document, exactly what the add page's | ||
| 19 | * untouched defaults submit. Registry spec overrides ride along. */ | ||
| 20 | function useOpenApiQuickAdd(): ( | ||
| 21 | input: IntegrationQuickAddInput, | ||
| 22 | ) => Promise<IntegrationQuickAddResult> { | ||
| 23 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | ||
| 24 | return useCallback( | ||
| 25 | async (input) => { | ||
| 26 | const slug = slugifyNamespace(input.slug ?? input.name); | ||
| 27 | if (!slug) return { ok: false, reason: "no derivable slug" }; | ||
| 28 | // Registry overrides arrive as untyped JSON; a malformed patch goes to | ||
| 29 | // the configuration screen (its editor renders the parse error) rather | ||
| 30 | // than being silently dropped from a "successful" quick add. | ||
| 31 | const specOverrides = input.specOverrides | ||
| 32 | ? decodeOpenApiSpecOverrides(input.specOverrides) | ||
| 33 | : undefined; | ||
| 34 | if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { | ||
| 35 | return { ok: false, reason: "unparseable spec overrides" }; | ||
| 36 | } | ||
| 37 | const exit = await doAdd({ | ||
| 38 | payload: { | ||
| 39 | spec: { kind: "url" as const, url: input.url }, | ||
| 40 | slug, | ||
| 41 | name: input.name, | ||
| 42 | ...(specOverrides && specOverrides.length > 0 ? { specOverrides } : {}), | ||
| 43 | }, | ||
| 44 | reactivityKeys: integrationWriteKeys, | ||
| 45 | }); | ||
| 46 | if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | ||
| 47 | return { ok: true, slug: String(exit.value.slug) }; | ||
| 48 | }, | ||
| 49 | [doAdd], | ||
| 50 | ); | ||
| 51 | } | ||
| 4 | 52 | ||
| 5 | const importAdd = () => import("./AddOpenApiIntegration"); | 53 | const importAdd = () => import("./AddOpenApiIntegration"); |
| 6 | const importEditSheet = () => import("./UpdateSpecSection"); | 54 | const importEditSheet = () => import("./UpdateSpecSection"); |
| @@ -24,6 +72,7 @@ export const createOpenApiIntegrationPlugin = ( · 17 unmodified lines | |||
| 24 | void importEditSheet(); | 72 | void importEditSheet(); |
| 25 | void importAccounts(); | 73 | void importAccounts(); |
| 26 | }, | 74 | }, |
| 75 | useQuickAdd: useOpenApiQuickAdd, | ||
| 27 | }); | 76 | }); |
| 28 | 77 | ||
| 29 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); | 78 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| @@ -10,6 +10,8 @@ import { · 4 unmodified lines | |||
| 10 | useIntegrationPlugins, | 10 | useIntegrationPlugins, |
| 11 | type IntegrationPlugin, | 11 | type IntegrationPlugin, |
| 12 | type IntegrationPreset, | 12 | type IntegrationPreset, |
| 13 | type IntegrationQuickAddInput, | ||
| 14 | type IntegrationQuickAddResult, | ||
| 13 | } from "@executor-js/sdk/client"; | 15 | } from "@executor-js/sdk/client"; |
| 14 | 16 | ||
| 15 | import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; | 17 | import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; |
| @@ -164,6 +166,10 @@ interface Row { · 148 unmodified lines | |||
| 164 | readonly iconUrl?: string; | 166 | readonly iconUrl?: string; |
| 165 | readonly onSelect: () => void; | 167 | readonly onSelect: () => void; |
| 166 | readonly added: boolean; | 168 | readonly added: boolean; |
| 169 | /** Namespace of the installed integration this row matched — the View | ||
| 170 | * destination. Known for quick-added rows and for rows whose installed | ||
| 171 | * match came from the catalog list. */ | ||
| 172 | readonly viewSlug?: string; | ||
| 167 | readonly busy: boolean; | 173 | readonly busy: boolean; |
| 168 | /** A click-time failure, rendered on this card rather than page-top. */ | 174 | /** A click-time failure, rendered on this card rather than page-top. */ |
| 169 | readonly error?: string; | 175 | readonly error?: string; |
| @@ -240,10 +246,27 @@ function ResultCard(props: { readonly row: Row }) { · 70 unmodified lines | |||
| 240 | <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} /> | 246 | <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} /> |
| 241 | </span> | 247 | </span> |
| 242 | {row.added ? ( | 248 | {row.added ? ( |
| 243 | <span className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground"> | 249 | // The add happened HERE — the card is the receipt. View jumps to |
| 244 | <CheckIcon className="size-3.5" aria-hidden /> | 250 | // the integration's hub, where authenticating happens; the check |
| 245 | Added | 251 | // stays so the state reads at a glance. |
| 246 | </span> | 252 | row.viewSlug ? ( |
| 253 | <span className="flex items-center gap-1.5"> | ||
| 254 | <CheckIcon className="size-3.5 text-muted-foreground" aria-hidden /> | ||
| 255 | <Button asChild variant="outline" size="sm" aria-label={`View ${row.title}`}> | ||
| 256 | <Link | ||
| 257 | to="/{-$orgSlug}/integrations/$namespace" | ||
| 258 | params={{ namespace: row.viewSlug }} | ||
| 259 | > | ||
| 260 | View | ||
| 261 | </Link> | ||
| 262 | </Button> | ||
| 263 | </span> | ||
| 264 | ) : ( | ||
| 265 | <span className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground"> | ||
| 266 | <CheckIcon className="size-3.5" aria-hidden /> | ||
| 267 | Added | ||
| 268 | </span> | ||
| 269 | ) | ||
| 247 | ) : row.busy ? ( | 270 | ) : row.busy ? ( |
| 248 | <span className="px-2 py-1.5 text-xs text-muted-foreground">Adding…</span> | 271 | <span className="px-2 py-1.5 text-xs text-muted-foreground">Adding…</span> |
| 249 | ) : ( | 272 | ) : ( |
| @@ -297,6 +320,29 @@ function CardSkeleton(props: { readonly index: number }) { · 47 unmodified lines | |||
| 297 | ); | 320 | ); |
| 298 | } | 321 | } |
| 299 | 322 | ||
| 323 | // --------------------------------------------------------------------------- | ||
| 324 | // Quick add | ||
| 325 | // --------------------------------------------------------------------------- | ||
| 326 | |||
| 327 | type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult>; | ||
| 328 | |||
| 329 | /** Collects each plugin's bound quick-add callback into the page's map. | ||
| 330 | * A component per plugin, not a hook over a list: `useQuickAdd` is a hook, | ||
| 331 | * and mounting one bridge per plugin is how a fixed set of per-plugin hooks | ||
| 332 | * stays rules-of-hooks clean. */ | ||
| 333 | function QuickAddBridge(props: { | ||
| 334 | readonly plugin: IntegrationPlugin; | ||
| 335 | readonly register: (key: string, fn: QuickAddFn | null) => void; | ||
| 336 | }) { | ||
| 337 | const { plugin, register } = props; | ||
| 338 | const fn = plugin.useQuickAdd?.() ?? null; | ||
| 339 | useEffect(() => { | ||
| 340 | register(plugin.key, fn); | ||
| 341 | return () => register(plugin.key, null); | ||
| 342 | }, [plugin.key, fn, register]); | ||
| 343 | return null; | ||
| 344 | } | ||
| 345 | |||
| 300 | // --------------------------------------------------------------------------- | 346 | // --------------------------------------------------------------------------- |
| 301 | // Page | 347 | // Page |
| 302 | // --------------------------------------------------------------------------- | 348 | // --------------------------------------------------------------------------- |
| @@ -318,6 +364,17 @@ export function IntegrationBrowsePage() { · 15 unmodified lines | |||
| 318 | readonly message: string; | 364 | readonly message: string; |
| 319 | } | null>(null); | 365 | } | null>(null); |
| 320 | const [resolvingDomain, setResolvingDomain] = useState<string | null>(null); | 366 | const [resolvingDomain, setResolvingDomain] = useState<string | null>(null); |
| 367 | // One-click adds run per-card and CONCURRENTLY — adding Linear must not | ||
| 368 | // lock the Notion card. Keyed by row error-key (`domain|kind`). | ||
| 369 | const [quickAddingKeys, setQuickAddingKeys] = useState<ReadonlySet<string>>(new Set()); | ||
| 370 | // Slugs of integrations added from this page, keyed the same way, so the | ||
| 371 | // card can flip to View without waiting for the catalog refetch. | ||
| 372 | const [quickAddedSlugs, setQuickAddedSlugs] = useState<ReadonlyMap<string, string>>(new Map()); | ||
| 373 | const quickAdders = useRef(new Map<string, QuickAddFn>()); | ||
| 374 | const registerQuickAdd = useCallback((key: string, fn: QuickAddFn | null) => { | ||
| 375 | if (fn) quickAdders.current.set(key, fn); | ||
| 376 | else quickAdders.current.delete(key); | ||
| 377 | }, []); | ||
| 321 | 378 | ||
| 322 | const isUrl = looksLikeUrl(query); | 379 | const isUrl = looksLikeUrl(query); |
| 323 | // A URL is a destination, not a filter — but half-typed URLs pass through | 380 | // A URL is a destination, not a filter — but half-typed URLs pass through |
| @@ -376,6 +433,21 @@ export function IntegrationBrowsePage() { · 52 unmodified lines | |||
| 376 | [installedKeys], | 433 | [installedKeys], |
| 377 | ); | 434 | ); |
| 378 | 435 | ||
| 436 | /** The installed integration's own namespace for an added row — the View | ||
| 437 | * button's destination. Same matching rules as `isAdded`. */ | ||
| 438 | const installedSlugFor = useCallback( | ||
| 439 | (kind: string, ...candidates: readonly (string | undefined)[]): string | undefined => { | ||
| 440 | for (const candidate of candidates) { | ||
| 441 | if (!candidate) continue; | ||
| 442 | if (installedKeys.has(`${candidate}:${kind}`)) return candidate; | ||
| 443 | const slugified = slugifyNamespace(candidate); | ||
| 444 | if (installedKeys.has(`${slugified}:${kind}`)) return slugified; | ||
| 445 | } | ||
| 446 | return undefined; | ||
| 447 | }, | ||
| 448 | [installedKeys], | ||
| 449 | ); | ||
| 450 | |||
| 379 | const allPresets = useMemo(() => { | 451 | const allPresets = useMemo(() => { |
| 380 | const entries: PresetEntry[] = []; | 452 | const entries: PresetEntry[] = []; |
| 381 | for (const plugin of integrationPlugins) { | 453 | for (const plugin of integrationPlugins) { |
| @@ -456,12 +528,79 @@ export function IntegrationBrowsePage() { · 74 unmodified lines | |||
| 456 | [navigate], | 528 | [navigate], |
| 457 | ); | 529 | ); |
| 458 | 530 | ||
| 531 | /** One-click add, in place. The registry knew the URL and the auth | ||
| 532 | * indicators, so there is nothing to configure — register directly and | ||
| 533 | * flip the card to View, leaving the user on this page to keep adding. | ||
| 534 | * Returns false when it could not (no quick-add for the kind, probe or | ||
| 535 | * registration failed), and the caller falls back to the configuration | ||
| 536 | * screen, which renders the same failure with full context. */ | ||
| 537 | const tryQuickAdd = useCallback( | ||
| 538 | async (input: { | ||
| 539 | readonly kind: CatalogKind; | ||
| 540 | readonly url: string; | ||
| 541 | readonly title: string; | ||
| 542 | readonly domain: string; | ||
| 543 | /** The row's identity key — carries the PRODUCT, not just domain|kind: | ||
| 544 | * one domain can offer two products of the same kind (the Google | ||
| 545 | * Photos Library and Picker), and a domain-level key marked both cards | ||
| 546 | * added when either one was. */ | ||
| 547 | readonly rowKey: string; | ||
| 548 | readonly slug?: string; | ||
| 549 | readonly auth?: CatalogSurface["auth"]; | ||
| 550 | readonly specOverrides?: CatalogSurface["specOverrides"]; | ||
| 551 | }): Promise<boolean> => { | ||
| 552 | const fn = quickAdders.current.get(KIND_TO_PLUGIN_KEY[input.kind] ?? input.kind); | ||
| 553 | if (!fn) return false; | ||
| 554 | const rowKey = input.rowKey; | ||
| 555 | setQuickAddingKeys((previous) => new Set(previous).add(rowKey)); | ||
| 556 | trackEvent("integration_add_started", { | ||
| 557 | plugin_key: input.kind, | ||
| 558 | via: "catalog", | ||
| 559 | catalog_domain: input.domain, | ||
| 560 | }); | ||
| 561 | const result = await fn({ | ||
| 562 | url: input.url, | ||
| 563 | name: input.title, | ||
| 564 | ...(input.slug ? { slug: input.slug } : {}), | ||
| 565 | ...(input.auth?.header ? { authHeader: input.auth.header } : {}), | ||
| 566 | ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), | ||
| 567 | ...(input.specOverrides ? { specOverrides: input.specOverrides } : {}), | ||
| 568 | }); | ||
| 569 | setQuickAddingKeys((previous) => { | ||
| 570 | const next = new Set(previous); | ||
| 571 | next.delete(rowKey); | ||
| 572 | return next; | ||
| 573 | }); | ||
| 574 | if (!result.ok) return false; | ||
| 575 | setQuickAddedSlugs((previous) => new Map(previous).set(rowKey, result.slug)); | ||
| 576 | return true; | ||
| 577 | }, | ||
| 578 | [], | ||
| 579 | ); | ||
| 580 | |||
| 459 | const pickCatalogEntry = useCallback( | 581 | const pickCatalogEntry = useCallback( |
| 460 | async (entry: CatalogSearchEntry, kind: CatalogKind, knownUrl?: string) => { | 582 | async ( |
| 583 | entry: CatalogSearchEntry, | ||
| 584 | kind: CatalogKind, | ||
| 585 | title: string, | ||
| 586 | rowKey: string, | ||
| 587 | knownUrl?: string, | ||
| 588 | ) => { | ||
| 461 | // The registry already told us where this surface lives — go, rather than | 589 | // The registry already told us where this surface lives — go, rather than |
| 462 | // spending a round trip re-asking for something we were handed. | 590 | // spending a round trip re-asking for something we were handed. |
| 463 | const surface = entry.surfaces?.find((candidate) => candidate.kind === kind); | 591 | const surface = entry.surfaces?.find((candidate) => candidate.kind === kind); |
| 464 | if (knownUrl) { | 592 | if (knownUrl) { |
| 593 | const added = await tryQuickAdd({ | ||
| 594 | kind, | ||
| 595 | url: knownUrl, | ||
| 596 | title, | ||
| 597 | domain: entry.domain, | ||
| 598 | rowKey, | ||
| 599 | ...(surface ? { slug: surface.slug } : {}), | ||
| 600 | ...(surface?.auth ? { auth: surface.auth } : {}), | ||
| 601 | ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}), | ||
| 602 | }); | ||
| 603 | if (added) return; | ||
| 465 | goToAdd({ | 604 | goToAdd({ |
| 466 | kind, | 605 | kind, |
| 467 | url: knownUrl, | 606 | url: knownUrl, |
| @@ -479,12 +618,21 @@ export function IntegrationBrowsePage() { · 11 unmodified lines | |||
| 479 | setResolvingDomain(null); | 618 | setResolvingDomain(null); |
| 480 | if (Exit.isFailure(exit) || !exit.value) { | 619 | if (Exit.isFailure(exit) || !exit.value) { |
| 481 | setRowError({ | 620 | setRowError({ |
| 482 | key: `${entry.domain}|${kind}`, | 621 | key: rowKey, |
| 483 | message: "Couldn't load connect details. Paste its URL above instead.", | 622 | message: "Couldn't load connect details. Paste its URL above instead.", |
| 484 | }); | 623 | }); |
| 485 | return; | 624 | return; |
| 486 | } | 625 | } |
| 487 | const target = exit.value; | 626 | const target = exit.value; |
| 627 | const added = await tryQuickAdd({ | ||
| 628 | kind: target.kind, | ||
| 629 | url: target.url, | ||
| 630 | title, | ||
| 631 | domain: entry.domain, | ||
| 632 | rowKey, | ||
| 633 | ...(target.slug ? { slug: target.slug } : {}), | ||
| 634 | }); | ||
| 635 | if (added) return; | ||
| 488 | goToAdd({ | 636 | goToAdd({ |
| 489 | kind: target.kind, | 637 | kind: target.kind, |
| 490 | url: target.url, | 638 | url: target.url, |
| @@ -492,7 +640,7 @@ export function IntegrationBrowsePage() { · 1 unmodified line | |||
| 492 | ...(target.slug ? { slug: target.slug } : {}), | 640 | ...(target.slug ? { slug: target.slug } : {}), |
| 493 | }); | 641 | }); |
| 494 | }, | 642 | }, |
| 495 | [goToAdd, resolvingDomain], | 643 | [goToAdd, resolvingDomain, tryQuickAdd], |
| 496 | ); | 644 | ); |
| 497 | 645 | ||
| 498 | const pickPreset = useCallback( | 646 | const pickPreset = useCallback( |
| @@ -564,23 +712,28 @@ export function IntegrationBrowsePage() { · 65 unmodified lines | |||
| 564 | return surfaces.map((surface): Row => { | 712 | return surfaces.map((surface): Row => { |
| 565 | const known = "slug" in surface ? surface : null; | 713 | const known = "slug" in surface ? surface : null; |
| 566 | const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; | 714 | const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; |
| 715 | const title = withSurface(pretty, word); | ||
| 716 | // Product identity, not just domain|kind: two same-kind products can | ||
| 717 | // share a domain (Google Photos Library and Picker). | ||
| 718 | const rowKey = `${entry.domain}|${surface.kind}|${known?.slug ?? pretty}`; | ||
| 719 | const quickAddedSlug = quickAddedSlugs.get(rowKey); | ||
| 720 | const viewSlug = quickAddedSlug ?? installedSlugFor(surface.kind, known?.slug); | ||
| 567 | // Slug in the key: one domain can carry many product surfaces of the | 721 | // Slug in the key: one domain can carry many product surfaces of the |
| 568 | // same kind (Microsoft Graph's workloads all live on one domain). | 722 | // same kind (Microsoft Graph's workloads all live on one domain). |
| 569 | return { | 723 | return { |
| 570 | key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, | 724 | key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, |
| 571 | testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, | 725 | testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, |
| 572 | title: withSurface(pretty, word), | 726 | title, |
| 573 | kindKey: surface.kind, | 727 | kindKey: surface.kind, |
| 574 | domain: entry.domain, | 728 | domain: entry.domain, |
| 575 | ...(description ? { description } : {}), | 729 | ...(description ? { description } : {}), |
| 576 | iconUrl: | 730 | iconUrl: |
| 577 | (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), | 731 | (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), |
| 578 | onSelect: () => void pickCatalogEntry(entry, surface.kind, known?.url), | 732 | onSelect: () => void pickCatalogEntry(entry, surface.kind, title, rowKey, known?.url), |
| 579 | added: isAdded(surface.kind, known?.slug), | 733 | added: quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug), |
| 580 | busy: resolvingDomain === entry.domain, | 734 | ...(viewSlug ? { viewSlug } : {}), |
| 581 | ...(rowError?.key === `${entry.domain}|${surface.kind}` | 735 | busy: resolvingDomain === entry.domain || quickAddingKeys.has(rowKey), |
| 582 | ? { error: rowError.message } | 736 | ...(rowError?.key === rowKey ? { error: rowError.message } : {}), |
| 583 | : {}), | ||
| 584 | }; | 737 | }; |
| 585 | }); | 738 | }); |
| 586 | }); | 739 | }); |
| @@ -598,7 +751,18 @@ export function IntegrationBrowsePage() { · 11 unmodified lines | |||
| 598 | // Gmail. Stable within each group, so the registry's own order survives. | 751 | // Gmail. Stable within each group, so the registry's own order survives. |
| 599 | const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text); | 752 | const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text); |
| 600 | return [...rows.filter(isNamed), ...rows.filter((row) => !isNamed(row))]; | 753 | return [...rows.filter(isNamed), ...rows.filter((row) => !isNamed(row))]; |
| 601 | }, [catalogEntries, isAdded, resolvingDomain, pickCatalogEntry, text, presetRows, rowError]); | 754 | }, [ |
| 755 | catalogEntries, | ||
| 756 | isAdded, | ||
| 757 | installedSlugFor, | ||
| 758 | quickAddedSlugs, | ||
| 759 | quickAddingKeys, | ||
| 760 | resolvingDomain, | ||
| 761 | pickCatalogEntry, | ||
| 762 | text, | ||
| 763 | presetRows, | ||
| 764 | rowError, | ||
| 765 | ]); | ||
| 602 | 766 | ||
| 603 | // Local-process cards hold no rank of their own, so each slots in where its | 767 | // Local-process cards hold no rank of their own, so each slots in where its |
| 604 | // title falls alphabetically among the surrounding rows — pinning them to | 768 | // title falls alphabetically among the surrounding rows — pinning them to |
| @@ -625,6 +789,9 @@ export function IntegrationBrowsePage() { · 20 unmodified lines | |||
| 625 | 789 | ||
| 626 | return ( | 790 | return ( |
| 627 | <PageContainer> | 791 | <PageContainer> |
| 792 | {integrationPlugins.map((plugin) => ( | ||
| 793 | <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} /> | ||
| 794 | ))} | ||
| 628 | <PageHeader | 795 | <PageHeader |
| 629 | title="Add an integration" | 796 | title="Add an integration" |
| 630 | description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." | 797 | description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." |