History

One-click add from the picker

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).

Rhys Sullivan committed 2 months ago

Commit f2d7fb61 parent 63741dd11 files · +554 −101Browse files

Unified · Split

11 files
  1. e2e/scenarios/google-photos-preset-ui.test.ts +19 −5
  2. e2e/scenarios/integrations-catalog-search.test.ts +68 −28
  3. e2e/scenarios/provider-plugins-ui.test.ts +15 −5
  4. packages/core/sdk/src/client.ts +35 −0
  5. packages/plugins/graphql/src/react/integration-plugin.ts +46 −2
  6. packages/plugins/mcp/src/api/group.ts +6 −0
  7. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +15 −41
  8. packages/plugins/mcp/src/react/auth-method-config.ts +53 −0
  9. packages/plugins/mcp/src/react/integration-plugin.tsx +63 −2
  10. packages/plugins/openapi/src/react/integration-plugin.ts +51 −2
  11. packages/react/src/pages/integration-browse.tsx +183 −16
e2e/scenarios/google-photos-preset-ui.test.ts+19−5
@@ -68,16 +68,30 @@ scenario( · 67 unmodified lines
6868 await cards.filter({ hasText: "Google Photos Picker API" }).waitFor();
6969 });
7070
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 () => {
7272 await page
7373 .getByTestId("catalog-photos.google.com-openapi")
7474 .filter({ hasText: "Google Photos Library API" })
7575 .getByRole("button", { name: "Add Google Photos Library API" })
7676 .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();
8195 });
8296 });
8397 }),
e2e/scenarios/integrations-catalog-search.test.ts+68−28
@@ -1,24 +1,35 @@
1+import { randomBytes } from "node:crypto";
2+
13 import { expect } from "@effect/vitest";
24 import { Effect } from "effect";
5+import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
36
47 import { scenario } from "../src/scenario";
58 import { Browser, Target } from "../src/services";
69 import { visit } from "../src/surfaces/browser";
710
811 // 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.
1217 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",
1419 {},
1520 Effect.gen(function* () {
1621 const target = yield* Target;
1722 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+ );
1829 const identity = yield* target.newIdentity();
1930
2031 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 () => {
2233 await page.route("https://integrations.sh/api/search*", (route) =>
2334 route.fulfill({
2435 contentType: "application/json",
@@ -30,44 +41,73 @@ scenario( · 5 unmodified lines
3041 name: "todoist.com",
3142 description: "Tasks, projects, and collaboration.",
3243 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+ ],
3457 },
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" },
4958 ],
5059 },
5160 }),
5261 );
5362 });
5463
55− await step("Searching surfaces the catalog row under the presets", async () => {
64+ await step("Searching surfaces the connectable rows, not the CLI", async () => {
5665 await visit(page, "/integrations/browse");
5766 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);
6070 });
6171
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 () => {
6373 await page
64− .getByTestId("catalog-todoist.com-mcp")
74+ .getByTestId(`catalog-todoist-${suffix}`)
6575 .getByRole("button", { name: "Add Todoist MCP" })
6676 .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 });
68108 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");
71111 });
72112 });
73113 }),
e2e/scenarios/provider-plugins-ui.test.ts+15−5
@@ -78,15 +78,25 @@ scenario( · 77 unmodified lines
7878 await card.getByText("gmail.com").waitFor();
7979 });
8080
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 () => {
8282 await page
8383 .getByTestId("catalog-gmail.com-openapi")
8484 .getByRole("button", { name: "Add Gmail API" })
8585 .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");
90100 });
91101
92102 await step("Search for Outlook and see its separate registry card and domain", async () => {
packages/core/sdk/src/client.ts+35−0
@@ -195,6 +195,30 @@ export interface EditSheetSectionProps { · 194 unmodified lines
195195 readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;
196196 }
197197
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+
198222 export interface IntegrationPlugin {
199223 /** Unique key matching the SDK plugin id (e.g. "openapi"). */
200224 readonly key: string;
@@ -256,6 +280,17 @@ export interface IntegrationPlugin { · 55 unmodified lines
256280 * Call from the host on intent (hover/focus) so the chunks land before the
257281 * user navigates into the add page. Idempotent. */
258282 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>;
259294 }
260295
261296 // ---------------------------------------------------------------------------
packages/plugins/graphql/src/react/integration-plugin.ts+46−2
@@ -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";
312 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+}
447
548 const importAdd = () => import("./AddGraphqlIntegration");
649 const importAccounts = () => import("./GraphqlAccountsPanel");
@@ -17,4 +60,5 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 10 unmodified lines
1760 void importAdd();
1861 void importAccounts();
1962 },
63+ useQuickAdd: useGraphqlQuickAdd,
2064 };
packages/plugins/mcp/src/api/group.ts+6−0
@@ -85,6 +85,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 84 unmodified lines
8585 serverName: Schema.NullOr(Schema.String),
8686 /** Server `instructions` from initialize — prefills the description field. */
8787 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"])),
8894 });
8995
9096 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+15−41
@@ -41,7 +41,11 @@ import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placemen · 40 unmodified lines
4141 import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
4242 import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
4343 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";
4549 import { parseStdioArgs } from "./stdio-fields";
4650 import { isProbableMcpEndpoint } from "./probe-url";
4751 import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
@@ -219,46 +223,16 @@ export default function AddMcpIntegration(props: { · 171 unmodified lines
219223 // The probe seeds the method list: detected OAuth → an OAuth row; a 401
220224 // without OAuth metadata → a bearer-header row; an open server → a no-auth
221225 // 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+ );
262236 const authMethodList = useAuthMethodList(authMethodSeeds);
263237
264238 const remoteIdentity = useIntegrationIdentity({
packages/plugins/mcp/src/react/auth-method-config.ts+53−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
99
1010 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
1111 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
12+import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor";
1213 import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
1314 import {
1415 authMethodFromSharedTemplate,
@@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines
126127 if (wire.length === 0) return [];
127128 return [{ kind: "apikey", placements: wire }];
128129 }
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+}
packages/plugins/mcp/src/react/integration-plugin.tsx+63−2
@@ -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";
312 import { mcpPresets } from "../sdk/presets";
13+import { addMcpServer, probeMcpEndpoint } from "./atoms";
14+import {
15+ mcpAuthMethodInputFromEditorValue,
16+ mcpDetectedAuthSeeds,
17+ mcpWireAuthInput,
18+} from "./auth-method-config";
419
520 const importAdd = () => import("./AddMcpIntegration");
621 const importEditSheet = () => import("./EditMcpIntegration");
@@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines
1227
1328 type AddProps = ComponentProps<IntegrationPlugin["add"]>;
1429
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+
1575 export interface McpIntegrationPluginOptions {
1676 /**
1777 * Enable the stdio transport in the add-integration UI (tab + presets).
@@ -50,6 +110,7 @@ export const createMcpIntegrationPlugin = ( · 32 unmodified lines
50110 void importEditSheet();
51111 void importAccounts();
52112 },
113+ useQuickAdd: useMcpQuickAdd,
53114 };
54115 };
55116
packages/plugins/openapi/src/react/integration-plugin.ts+51−2
@@ -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";
312 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+}
452
553 const importAdd = () => import("./AddOpenApiIntegration");
654 const importEditSheet = () => import("./UpdateSpecSection");
@@ -24,6 +72,7 @@ export const createOpenApiIntegrationPlugin = ( · 17 unmodified lines
2472 void importEditSheet();
2573 void importAccounts();
2674 },
75+ useQuickAdd: useOpenApiQuickAdd,
2776 });
2877
2978 export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/react/src/pages/integration-browse.tsx+183−16
@@ -1,5 +1,5 @@
11 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";
33 import { useAtomSet, useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55 import * as Effect from "effect/Effect";
@@ -10,6 +10,8 @@ import { · 4 unmodified lines
1010 useIntegrationPlugins,
1111 type IntegrationPlugin,
1212 type IntegrationPreset,
13+ type IntegrationQuickAddInput,
14+ type IntegrationQuickAddResult,
1315 } from "@executor-js/sdk/client";
1416
1517 import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms";
@@ -164,6 +166,10 @@ interface Row { · 148 unmodified lines
164166 readonly iconUrl?: string;
165167 readonly onSelect: () => void;
166168 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;
167173 readonly busy: boolean;
168174 /** A click-time failure, rendered on this card rather than page-top. */
169175 readonly error?: string;
@@ -240,10 +246,27 @@ function ResultCard(props: { readonly row: Row }) { · 70 unmodified lines
240246 <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} />
241247 </span>
242248 {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+ )
247270 ) : row.busy ? (
248271 <span className="px-2 py-1.5 text-xs text-muted-foreground">Adding…</span>
249272 ) : (
@@ -297,6 +320,29 @@ function CardSkeleton(props: { readonly index: number }) { · 47 unmodified lines
297320 );
298321 }
299322
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+
300346 // ---------------------------------------------------------------------------
301347 // Page
302348 // ---------------------------------------------------------------------------
@@ -318,6 +364,17 @@ export function IntegrationBrowsePage() { · 15 unmodified lines
318364 readonly message: string;
319365 } | null>(null);
320366 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+ }, []);
321378
322379 const isUrl = looksLikeUrl(query);
323380 // A URL is a destination, not a filter — but half-typed URLs pass through
@@ -376,6 +433,21 @@ export function IntegrationBrowsePage() { · 52 unmodified lines
376433 [installedKeys],
377434 );
378435
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+
379451 const allPresets = useMemo(() => {
380452 const entries: PresetEntry[] = [];
381453 for (const plugin of integrationPlugins) {
@@ -456,12 +528,79 @@ export function IntegrationBrowsePage() { · 74 unmodified lines
456528 [navigate],
457529 );
458530
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+
459581 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+ ) => {
461589 // The registry already told us where this surface lives — go, rather than
462590 // spending a round trip re-asking for something we were handed.
463591 const surface = entry.surfaces?.find((candidate) => candidate.kind === kind);
464592 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;
465604 goToAdd({
466605 kind,
467606 url: knownUrl,
@@ -479,12 +618,21 @@ export function IntegrationBrowsePage() { · 11 unmodified lines
479618 setResolvingDomain(null);
480619 if (Exit.isFailure(exit) || !exit.value) {
481620 setRowError({
482− key: `${entry.domain}|${kind}`,
621+ key: rowKey,
483622 message: "Couldn't load connect details. Paste its URL above instead.",
484623 });
485624 return;
486625 }
487626 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;
488636 goToAdd({
489637 kind: target.kind,
490638 url: target.url,
@@ -492,7 +640,7 @@ export function IntegrationBrowsePage() { · 1 unmodified line
492640 ...(target.slug ? { slug: target.slug } : {}),
493641 });
494642 },
495− [goToAdd, resolvingDomain],
643+ [goToAdd, resolvingDomain, tryQuickAdd],
496644 );
497645
498646 const pickPreset = useCallback(
@@ -564,23 +712,28 @@ export function IntegrationBrowsePage() { · 65 unmodified lines
564712 return surfaces.map((surface): Row => {
565713 const known = "slug" in surface ? surface : null;
566714 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);
567721 // Slug in the key: one domain can carry many product surfaces of the
568722 // same kind (Microsoft Graph's workloads all live on one domain).
569723 return {
570724 key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`,
571725 testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`,
572− title: withSurface(pretty, word),
726+ title,
573727 kindKey: surface.kind,
574728 domain: entry.domain,
575729 ...(description ? { description } : {}),
576730 iconUrl:
577731 (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 } : {}),
584737 };
585738 });
586739 });
@@ -598,7 +751,18 @@ export function IntegrationBrowsePage() { · 11 unmodified lines
598751 // Gmail. Stable within each group, so the registry's own order survives.
599752 const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text);
600753 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+ ]);
602766
603767 // Local-process cards hold no rank of their own, so each slots in where its
604768 // title falls alphabetically among the surrounding rows — pinning them to
@@ -625,6 +789,9 @@ export function IntegrationBrowsePage() { · 20 unmodified lines
625789
626790 return (
627791 <PageContainer>
792+ {integrationPlugins.map((plugin) => (
793+ <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} />
794+ ))}
628795 <PageHeader
629796 title="Add an integration"
630797 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."