Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-integration-families.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions e2e/scenarios/integration-families.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof api>;

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);
}),
);
}),
);
2 changes: 2 additions & 0 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)),
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const toServerInput = (
const p = payload as {
transport: "stdio";
name: string;
family?: string;
description?: string;
command: string;
args?: readonly string[];
Expand All @@ -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,
Expand All @@ -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";
Expand All @@ -72,6 +75,7 @@ const toServerInput = (
return {
transport: "remote",
name: p.name,
family: p.family,
description: p.description,
endpoint: p.endpoint,
remoteTransport: p.remoteTransport,
Expand Down
18 changes: 18 additions & 0 deletions packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })),
Expand Down
17 changes: 17 additions & 0 deletions packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
16 changes: 13 additions & 3 deletions packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 } : {}),
};
};

// ---------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 */
Expand Down
15 changes: 14 additions & 1 deletion packages/react/src/lib/integration-grouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 1 addition & 3 deletions packages/react/src/lib/integration-grouping.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { Integration } from "@executor-js/sdk/shared";

export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);

const FAMILY_LABELS: Record<string, string> = {
google: "Google",
microsoft: "Microsoft",
Expand All @@ -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 {
Expand Down