From 1ee665c5076dab903072bb623fc4167e01b92131 Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 20 Aug 2026 18:16:35 +0300 Subject: [PATCH] feat(mcp): support integration families --- .changeset/mcp-integration-families.md | 5 ++ e2e/scenarios/integration-families.test.ts | 69 +++++++++++++++++++ packages/plugins/mcp/src/api/group.ts | 2 + packages/plugins/mcp/src/api/handlers.ts | 4 ++ .../mcp/src/sdk/describe-auth-methods.test.ts | 18 +++++ packages/plugins/mcp/src/sdk/plugin.test.ts | 17 +++++ packages/plugins/mcp/src/sdk/plugin.ts | 16 ++++- packages/plugins/mcp/src/sdk/types.ts | 4 ++ .../src/lib/integration-grouping.test.ts | 15 +++- .../react/src/lib/integration-grouping.ts | 4 +- 10 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 .changeset/mcp-integration-families.md create mode 100644 e2e/scenarios/integration-families.test.ts diff --git a/.changeset/mcp-integration-families.md b/.changeset/mcp-integration-families.md new file mode 100644 index 0000000000..2cd001d94c --- /dev/null +++ b/.changeset/mcp-integration-families.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +Allow MCP integrations to declare a catalog family and group any family with multiple services in the integrations UI. diff --git a/e2e/scenarios/integration-families.test.ts b/e2e/scenarios/integration-families.test.ts new file mode 100644 index 0000000000..3db53e4ff0 --- /dev/null +++ b/e2e/scenarios/integration-families.test.ts @@ -0,0 +1,69 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +scenario( + "Integrations ยท related MCP services collapse into their declared family", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const client: Client = yield* makeClient(api, identity); + const suffix = randomBytes(4).toString("hex"); + const family = `cloudflare-${suffix}`; + const apiSlug = IntegrationSlug.make(`family-api-${suffix}`); + const docsSlug = IntegrationSlug.make(`family-docs-${suffix}`); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.mcp.addServer({ + payload: { + name: "Cloudflare API", + family, + endpoint: "https://api.example.com/mcp", + slug: String(apiSlug), + }, + }); + yield* client.mcp.addServer({ + payload: { + name: "Cloudflare Docs", + family, + endpoint: "https://docs.example.com/mcp", + slug: String(docsSlug), + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the integrations catalog", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByText("Integrations").first().waitFor(); + }); + + await step("The related MCP services share one family card", async () => { + const group = page.getByTestId(`integration-group-${family}`); + await group.waitFor(); + expect(await group.innerText()).toContain("Cloudflare API"); + expect(await group.innerText()).toContain("Cloudflare Docs"); + }); + }); + }), + Effect.gen(function* () { + yield* client.mcp.removeServer({ params: { slug: apiSlug } }).pipe(Effect.ignore); + yield* client.mcp.removeServer({ params: { slug: docsSlug } }).pipe(Effect.ignore); + }), + ); + }), +); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 324b9d6841..424a38c31e 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -31,6 +31,7 @@ const StringMap = Schema.Record(Schema.String, Schema.String); const AddRemoteServerPayload = Schema.Struct({ transport: Schema.optional(Schema.Literal("remote")), name: Schema.String, + family: Schema.optional(Schema.String), /** Agent-visible catalog description. Defaults to the display name. */ description: Schema.optional(Schema.String), endpoint: Schema.String, @@ -48,6 +49,7 @@ const AddRemoteServerPayload = Schema.Struct({ const AddStdioServerPayload = Schema.Struct({ transport: Schema.Literal("stdio"), name: Schema.String, + family: Schema.optional(Schema.String), description: Schema.optional(Schema.String), command: Schema.String, args: Schema.optional(Schema.Array(Schema.String)), diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 6ca5c3188a..74e8c9ec96 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -33,6 +33,7 @@ const toServerInput = ( const p = payload as { transport: "stdio"; name: string; + family?: string; description?: string; command: string; args?: readonly string[]; @@ -44,6 +45,7 @@ const toServerInput = ( return { transport: "stdio", name: p.name, + family: p.family, description: p.description, command: p.command, args: p.args ? [...p.args] : undefined, @@ -57,6 +59,7 @@ const toServerInput = ( const p = payload as { transport?: "remote"; name: string; + family?: string; description?: string; endpoint: string; remoteTransport?: "streamable-http" | "sse" | "auto"; @@ -72,6 +75,7 @@ const toServerInput = ( return { transport: "remote", name: p.name, + family: p.family, description: p.description, endpoint: p.endpoint, remoteTransport: p.remoteTransport, diff --git a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts index e8a9fe5a9f..89f1e80392 100644 --- a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts @@ -199,6 +199,24 @@ describe("describeMcpAuthMethods", () => { ).toEqual({ url: "https://mcp.posthog.com/mcp" }); }); + it("projects catalog family for remote and stdio integrations", () => { + expect( + describeMcpIntegrationDisplay( + recordWith({ + transport: "remote", + family: "cloudflare", + endpoint: "https://mcp.cloudflare.com/mcp", + authenticationTemplate: [{ slug: "none", kind: "none" }], + }), + ), + ).toEqual({ url: "https://mcp.cloudflare.com/mcp", family: "cloudflare" }); + expect( + describeMcpIntegrationDisplay( + recordWith({ transport: "stdio", family: "design", command: "design-mcp" }), + ), + ).toEqual({ family: "design" }); + }); + it("does not expose display metadata for stdio or malformed configs", () => { expect( describeMcpIntegrationDisplay(recordWith({ transport: "stdio", command: "run" })), diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 0b8338684b..0bed575927 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -346,6 +346,23 @@ describe("mcpPlugin", () => { }), ); + it.effect("projects an MCP server family into the integration catalog", () => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + yield* executor.mcp.addServer({ + name: "Cloudflare Docs", + family: "cloudflare", + endpoint: "https://example.com/mcp", + slug: "cloudflare_docs", + }); + + const integrations = yield* executor.integrations.list(); + expect(integrations.find((item) => item.slug === "cloudflare_docs")?.family).toBe( + "cloudflare", + ); + }), + ); + it.effect("connection tools list is empty until a connection is created", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e3b7a6857e..baf168550e 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -178,6 +178,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => const McpRemoteServerInputSchema = Schema.Struct({ transport: Schema.optional(Schema.Literal("remote")), name: Schema.String, + /** Optional catalog family used to group related integrations. */ + family: Schema.optional(Schema.String), /** Agent-visible catalog description. Defaults to the display name. */ description: Schema.optional(Schema.String), endpoint: Schema.String, @@ -195,6 +197,8 @@ const McpRemoteServerInputSchema = Schema.Struct({ const McpStdioServerInputSchema = Schema.Struct({ transport: Schema.Literal("stdio"), name: Schema.String, + /** Optional catalog family used to group related integrations. */ + family: Schema.optional(Schema.String), description: Schema.optional(Schema.String), command: Schema.String, args: Schema.optional(Schema.Array(Schema.String)), @@ -367,6 +371,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => const vars = stdioEnvVarNames(input); return { transport: "stdio", + family: input.family?.trim() || undefined, command: input.command, args: input.args ? [...input.args] : undefined, cwd: input.cwd, @@ -378,6 +383,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => } return { transport: "remote", + family: input.family?.trim() || undefined, endpoint: input.endpoint, remoteTransport: input.remoteTransport ?? "auto", queryParams: input.queryParams, @@ -704,10 +710,14 @@ export const describeMcpAuthMethods = ( export const describeMcpIntegrationDisplay = ( record: IntegrationRecord, -): { readonly url?: string } => { +): { readonly url?: string; readonly family?: string } => { const config = parseMcpIntegrationConfig(record.config); - if (!config || config.transport === "stdio") return {}; - return { url: config.endpoint }; + if (!config) return {}; + const family = config.family?.trim(); + return { + ...(config.transport === "remote" ? { url: config.endpoint } : {}), + ...(family ? { family } : {}), + }; }; // --------------------------------------------------------------------------- diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 838e81e0ac..387d80d394 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -176,6 +176,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); export const McpRemoteIntegrationConfig = Schema.Struct({ transport: Schema.Literal("remote"), + /** Optional catalog family used to group related integrations. */ + family: Schema.optional(Schema.String), /** The MCP server endpoint URL */ endpoint: Schema.String, /** Transport preference for this remote server */ @@ -195,6 +197,8 @@ export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; export const McpStdioIntegrationConfig = Schema.Struct({ transport: Schema.Literal("stdio"), + /** Optional catalog family used to group related integrations. */ + family: Schema.optional(Schema.String), /** The command to run */ command: Schema.String, /** Arguments to the command */ diff --git a/packages/react/src/lib/integration-grouping.test.ts b/packages/react/src/lib/integration-grouping.test.ts index 8151c93e69..0c56f6d859 100644 --- a/packages/react/src/lib/integration-grouping.test.ts +++ b/packages/react/src/lib/integration-grouping.test.ts @@ -38,6 +38,19 @@ describe("groupIntegrations", () => { ]); }); + it("groups arbitrary provider families", () => { + const items = groupIntegrations([ + integration("cloudflare_api", "mcp", "Cloudflare API", "cloudflare"), + integration("cloudflare_docs", "mcp", "Cloudflare Docs", "cloudflare"), + ]); + + expect(items).toHaveLength(1); + const group = items[0] as IntegrationFamilyGroup; + expect(group.type).toBe("group"); + expect(group.family).toBe("cloudflare"); + expect(group.label).toBe("Cloudflare"); + }); + it("leaves integrations without config family ungrouped", () => { const items = groupIntegrations([ integration("stripe", "openapi", "Stripe"), @@ -72,7 +85,7 @@ describe("groupIntegrations", () => { ).toEqual(["single", "group:google", "group:microsoft", "single"]); }); - it("does not group from integration kind alone", () => { + it("does not infer a family from integration kind alone", () => { expect( integrationFamily(integration("google_calendar", "google", "Google Calendar")), ).toBeNull(); diff --git a/packages/react/src/lib/integration-grouping.ts b/packages/react/src/lib/integration-grouping.ts index 1f0bd05d1c..d0dd8e537b 100644 --- a/packages/react/src/lib/integration-grouping.ts +++ b/packages/react/src/lib/integration-grouping.ts @@ -1,7 +1,5 @@ import type { Integration } from "@executor-js/sdk/shared"; -export const MULTI_SERVICE_FAMILIES: ReadonlySet = new Set(["google", "microsoft"]); - const FAMILY_LABELS: Record = { google: "Google", microsoft: "Microsoft", @@ -12,7 +10,7 @@ export const familyLabel = (family: string): string => export const integrationFamily = (integration: Integration): string | null => { const family = integration.family?.trim(); - return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null; + return family || null; }; export interface IntegrationFamilyGroup {