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