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 | 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 | 72 | await page |
| 73 | 73 | .getByTestId("catalog-photos.google.com-openapi") |
| 74 | 74 | .filter({ hasText: "Google Photos Library API" }) |
| 75 | 75 | .getByRole("button", { name: "Add Google Photos Library API" }) |
| 76 | 76 | .click(); |
| 77 | − await page.waitForURL(/\/integrations\/add\/openapi/); | |
| 78 | − const url = new URL(page.url()); | |
| 79 | − expect(url.searchParams.get("url")).toBe(librarySpecUrl); | |
| 80 | − expect(url.searchParams.get("namespace")).toBe("google-photos-library"); | |
| 77 | + const libraryCard = page | |
| 78 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 79 | + .filter({ hasText: "Google Photos Library API" }); | |
| 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 | 3 | import { expect } from "@effect/vitest"; |
| 2 | 4 | import { Effect } from "effect"; |
| 5 | +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 3 | 6 | |
| 4 | 7 | import { scenario } from "../src/scenario"; |
| 5 | 8 | import { Browser, Target } from "../src/services"; |
| 6 | 9 | import { visit } from "../src/surfaces/browser"; |
| 7 | 10 | |
| 8 | 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 | |
| 10 | −// endpoints are fulfilled at the network layer here — including the CORS header | |
| 11 | −// a real cross-origin browser call needs. | |
| 12 | +// the browser. CI must not depend on the live service, so the search endpoint | |
| 13 | +// is fulfilled at the network layer here — including the CORS header a real | |
| 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 | 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 | 20 | Effect.gen(function* () { |
| 16 | 21 | const target = yield* Target; |
| 17 | 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 | 29 | const identity = yield* target.newIdentity(); |
| 19 | 30 | |
| 20 | 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 | 33 | await page.route("https://integrations.sh/api/search*", (route) => |
| 23 | 34 | route.fulfill({ |
| 24 | 35 | contentType: "application/json", |
| @@ -30,44 +41,73 @@ scenario( · 5 unmodified lines | ||
| 30 | 41 | name: "todoist.com", |
| 31 | 42 | description: "Tasks, projects, and collaboration.", |
| 32 | 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 | 65 | await visit(page, "/integrations/browse"); |
| 57 | 66 | await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist"); |
| 58 | − // The CLI-only surface is not offered; the connectable kind is. | |
| 59 | − await page.getByTestId("catalog-todoist.com-mcp").getByText("MCP").waitFor(); | |
| 67 | + await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist 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 | 73 | await page |
| 64 | − .getByTestId("catalog-todoist.com-mcp") | |
| 74 | + .getByTestId(`catalog-todoist-${suffix}`) | |
| 65 | 75 | .getByRole("button", { name: "Add Todoist MCP" }) |
| 66 | 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 | 108 | const url = new URL(page.url()); |
| 69 | − expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp"); | |
| 70 | − expect(url.searchParams.get("namespace")).toBe("todoist"); | |
| 109 | + expect(url.searchParams.get("url")).toBe("https://mcp.notareal.invalid/mcp"); | |
| 110 | + expect(url.searchParams.get("namespace")).toBe("deadserver"); | |
| 71 | 111 | }); |
| 72 | 112 | }); |
| 73 | 113 | }), |
| @@ -78,15 +78,25 @@ scenario( · 77 unmodified lines | ||
| 78 | 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 | 82 | await page |
| 83 | 83 | .getByTestId("catalog-gmail.com-openapi") |
| 84 | 84 | .getByRole("button", { name: "Add Gmail API" }) |
| 85 | 85 | .click(); |
| 86 | − await page.waitForURL(/\/integrations\/add\/openapi/); | |
| 87 | − const url = new URL(page.url()); | |
| 88 | − expect(url.searchParams.get("url")).toBe(gmailSpecUrl); | |
| 89 | − expect(url.searchParams.get("namespace")).toBe("google-gmail"); | |
| 86 | + // The quick add fetches the hosted spec server-side and registers — | |
| 87 | + // no navigation. Generous timeout: a real spec fetch + parse rides | |
| 88 | + // behind the click. | |
| 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 | 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 | 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 | 222 | export interface IntegrationPlugin { |
| 199 | 223 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ |
| 200 | 224 | readonly key: string; |
| @@ -256,6 +280,17 @@ export interface IntegrationPlugin { · 55 unmodified lines | ||
| 256 | 280 | * Call from the host on intent (hover/focus) so the chunks land before the |
| 257 | 281 | * user navigates into the add page. Idempotent. */ |
| 258 | 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"; | |
| 2 | −import type { IntegrationPlugin } from "@executor-js/sdk/client"; | |
| 1 | +import { lazy, useCallback } from "react"; | |
| 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 | 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 | 48 | const importAdd = () => import("./AddGraphqlIntegration"); |
| 6 | 49 | const importAccounts = () => import("./GraphqlAccountsPanel"); |
| @@ -17,4 +60,5 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 10 unmodified lines | ||
| 17 | 60 | void importAdd(); |
| 18 | 61 | void importAccounts(); |
| 19 | 62 | }, |
| 63 | + useQuickAdd: useGraphqlQuickAdd, | |
| 20 | 64 | }; |
| @@ -85,6 +85,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 84 unmodified lines | ||
| 85 | 85 | serverName: Schema.NullOr(Schema.String), |
| 86 | 86 | /** Server `instructions` from initialize — prefills the description field. */ |
| 87 | 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 | 41 | import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; |
| 42 | 42 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 43 | 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 | 49 | import { parseStdioArgs } from "./stdio-fields"; |
| 46 | 50 | import { isProbableMcpEndpoint } from "./probe-url"; |
| 47 | 51 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; |
| @@ -219,46 +223,16 @@ export default function AddMcpIntegration(props: { · 171 unmodified lines | ||
| 219 | 223 | // The probe seeds the method list: detected OAuth → an OAuth row; a 401 |
| 220 | 224 | // without OAuth metadata → a bearer-header row; an open server → a no-auth |
| 221 | 225 | // row. The user can edit any row or add alternate methods alongside. |
| 222 | − const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => { | |
| 223 | − const registryPlacement = props.initialAuthHeader | |
| 224 | − ? placementFromHeaderPattern(props.initialAuthHeader) | |
| 225 | − : null; | |
| 226 | − if (!probe) { | |
| 227 | − // No probe result (pending, or the server was unreachable from here). | |
| 228 | − // The registry's declared facts still stand: an authless server or a | |
| 229 | − // known header pattern seeds the list the probe would have produced. | |
| 230 | − if (registryPlacement) | |
| 231 | − return [{ value: { kind: "apikey", placements: [registryPlacement] } }]; | |
| 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]); | |
| 226 | + const authMethodSeeds: readonly AuthMethodSeed[] = useMemo( | |
| 227 | + () => | |
| 228 | + mcpDetectedAuthSeeds(probe, { | |
| 229 | + placement: props.initialAuthHeader | |
| 230 | + ? placementFromHeaderPattern(props.initialAuthHeader) | |
| 231 | + : null, | |
| 232 | + kind: props.initialAuthKind, | |
| 233 | + }), | |
| 234 | + [probe, props.initialAuthHeader, props.initialAuthKind], | |
| 235 | + ); | |
| 262 | 236 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 263 | 237 | |
| 264 | 238 | const remoteIdentity = useIntegrationIdentity({ |
| @@ -9,6 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 11 | 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 | 13 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; |
| 13 | 14 | import { |
| 14 | 15 | authMethodFromSharedTemplate, |
| @@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines | ||
| 126 | 127 | if (wire.length === 0) return []; |
| 127 | 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"; | |
| 2 | −import type { IntegrationPlugin } from "@executor-js/sdk/client"; | |
| 1 | +import { lazy, useCallback, type ComponentProps, type ComponentType } from "react"; | |
| 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 | 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 | 20 | const importAdd = () => import("./AddMcpIntegration"); |
| 6 | 21 | const importEditSheet = () => import("./EditMcpIntegration"); |
| @@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines | ||
| 12 | 27 | |
| 13 | 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 | 75 | export interface McpIntegrationPluginOptions { |
| 16 | 76 | /** |
| 17 | 77 | * Enable the stdio transport in the add-integration UI (tab + presets). |
| @@ -50,6 +110,7 @@ export const createMcpIntegrationPlugin = ( · 32 unmodified lines | ||
| 50 | 110 | void importEditSheet(); |
| 51 | 111 | void importAccounts(); |
| 52 | 112 | }, |
| 113 | + useQuickAdd: useMcpQuickAdd, | |
| 53 | 114 | }; |
| 54 | 115 | }; |
| 55 | 116 | |
| @@ -1,6 +1,54 @@ | ||
| 1 | −import { lazy } from "react"; | |
| 2 | −import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client"; | |
| 1 | +import { lazy, useCallback } from "react"; | |
| 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 | 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 | 53 | const importAdd = () => import("./AddOpenApiIntegration"); |
| 6 | 54 | const importEditSheet = () => import("./UpdateSpecSection"); |
| @@ -24,6 +72,7 @@ export const createOpenApiIntegrationPlugin = ( · 17 unmodified lines | ||
| 24 | 72 | void importEditSheet(); |
| 25 | 73 | void importAccounts(); |
| 26 | 74 | }, |
| 75 | + useQuickAdd: useOpenApiQuickAdd, | |
| 27 | 76 | }); |
| 28 | 77 | |
| 29 | 78 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| @@ -10,6 +10,8 @@ import { · 4 unmodified lines | ||
| 10 | 10 | useIntegrationPlugins, |
| 11 | 11 | type IntegrationPlugin, |
| 12 | 12 | type IntegrationPreset, |
| 13 | + type IntegrationQuickAddInput, | |
| 14 | + type IntegrationQuickAddResult, | |
| 13 | 15 | } from "@executor-js/sdk/client"; |
| 14 | 16 | |
| 15 | 17 | import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; |
| @@ -164,6 +166,10 @@ interface Row { · 148 unmodified lines | ||
| 164 | 166 | readonly iconUrl?: string; |
| 165 | 167 | readonly onSelect: () => void; |
| 166 | 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 | 173 | readonly busy: boolean; |
| 168 | 174 | /** A click-time failure, rendered on this card rather than page-top. */ |
| 169 | 175 | readonly error?: string; |
| @@ -240,10 +246,27 @@ function ResultCard(props: { readonly row: Row }) { · 70 unmodified lines | ||
| 240 | 246 | <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} /> |
| 241 | 247 | </span> |
| 242 | 248 | {row.added ? ( |
| 243 | − <span className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground"> | |
| 244 | − <CheckIcon className="size-3.5" aria-hidden /> | |
| 245 | − Added | |
| 246 | − </span> | |
| 249 | + // The add happened HERE — the card is the receipt. View jumps to | |
| 250 | + // the integration's hub, where authenticating happens; the check | |
| 251 | + // stays so the state reads at a glance. | |
| 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 | 270 | ) : row.busy ? ( |
| 248 | 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 | 347 | // Page |
| 302 | 348 | // --------------------------------------------------------------------------- |
| @@ -318,6 +364,17 @@ export function IntegrationBrowsePage() { · 15 unmodified lines | ||
| 318 | 364 | readonly message: string; |
| 319 | 365 | } | null>(null); |
| 320 | 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 | 379 | const isUrl = looksLikeUrl(query); |
| 323 | 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 | 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 | 451 | const allPresets = useMemo(() => { |
| 380 | 452 | const entries: PresetEntry[] = []; |
| 381 | 453 | for (const plugin of integrationPlugins) { |
| @@ -456,12 +528,79 @@ export function IntegrationBrowsePage() { · 74 unmodified lines | ||
| 456 | 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 | 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 | 589 | // The registry already told us where this surface lives — go, rather than |
| 462 | 590 | // spending a round trip re-asking for something we were handed. |
| 463 | 591 | const surface = entry.surfaces?.find((candidate) => candidate.kind === kind); |
| 464 | 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 | 604 | goToAdd({ |
| 466 | 605 | kind, |
| 467 | 606 | url: knownUrl, |
| @@ -479,12 +618,21 @@ export function IntegrationBrowsePage() { · 11 unmodified lines | ||
| 479 | 618 | setResolvingDomain(null); |
| 480 | 619 | if (Exit.isFailure(exit) || !exit.value) { |
| 481 | 620 | setRowError({ |
| 482 | − key: `${entry.domain}|${kind}`, | |
| 621 | + key: rowKey, | |
| 483 | 622 | message: "Couldn't load connect details. Paste its URL above instead.", |
| 484 | 623 | }); |
| 485 | 624 | return; |
| 486 | 625 | } |
| 487 | 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 | 636 | goToAdd({ |
| 489 | 637 | kind: target.kind, |
| 490 | 638 | url: target.url, |
| @@ -492,7 +640,7 @@ export function IntegrationBrowsePage() { · 1 unmodified line | ||
| 492 | 640 | ...(target.slug ? { slug: target.slug } : {}), |
| 493 | 641 | }); |
| 494 | 642 | }, |
| 495 | − [goToAdd, resolvingDomain], | |
| 643 | + [goToAdd, resolvingDomain, tryQuickAdd], | |
| 496 | 644 | ); |
| 497 | 645 | |
| 498 | 646 | const pickPreset = useCallback( |
| @@ -564,23 +712,28 @@ export function IntegrationBrowsePage() { · 65 unmodified lines | ||
| 564 | 712 | return surfaces.map((surface): Row => { |
| 565 | 713 | const known = "slug" in surface ? surface : null; |
| 566 | 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 | 721 | // Slug in the key: one domain can carry many product surfaces of the |
| 568 | 722 | // same kind (Microsoft Graph's workloads all live on one domain). |
| 569 | 723 | return { |
| 570 | 724 | key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, |
| 571 | 725 | testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, |
| 572 | − title: withSurface(pretty, word), | |
| 726 | + title, | |
| 573 | 727 | kindKey: surface.kind, |
| 574 | 728 | domain: entry.domain, |
| 575 | 729 | ...(description ? { description } : {}), |
| 576 | 730 | iconUrl: |
| 577 | 731 | (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), |
| 578 | − onSelect: () => void pickCatalogEntry(entry, surface.kind, known?.url), | |
| 579 | − added: isAdded(surface.kind, known?.slug), | |
| 580 | − busy: resolvingDomain === entry.domain, | |
| 581 | − ...(rowError?.key === `${entry.domain}|${surface.kind}` | |
| 582 | − ? { error: rowError.message } | |
| 583 | − : {}), | |
| 732 | + onSelect: () => void pickCatalogEntry(entry, surface.kind, title, rowKey, known?.url), | |
| 733 | + added: quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug), | |
| 734 | + ...(viewSlug ? { viewSlug } : {}), | |
| 735 | + busy: resolvingDomain === entry.domain || quickAddingKeys.has(rowKey), | |
| 736 | + ...(rowError?.key === rowKey ? { error: rowError.message } : {}), | |
| 584 | 737 | }; |
| 585 | 738 | }); |
| 586 | 739 | }); |
| @@ -598,7 +751,18 @@ export function IntegrationBrowsePage() { · 11 unmodified lines | ||
| 598 | 751 | // Gmail. Stable within each group, so the registry's own order survives. |
| 599 | 752 | const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text); |
| 600 | 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 | 767 | // Local-process cards hold no rank of their own, so each slots in where its |
| 604 | 768 | // title falls alphabetically among the surrounding rows — pinning them to |
| @@ -625,6 +789,9 @@ export function IntegrationBrowsePage() { · 20 unmodified lines | ||
| 625 | 789 | |
| 626 | 790 | return ( |
| 627 | 791 | <PageContainer> |
| 792 | + {integrationPlugins.map((plugin) => ( | |
| 793 | + <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} /> | |
| 794 | + ))} | |
| 628 | 795 | <PageHeader |
| 629 | 796 | title="Add an integration" |
| 630 | 797 | description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." |