feat(connections): enable MCP aggregators by default (#13964)

## Thinking Path

> - Paperclip helps people manage AI agents for work.
> - Connections let those agents use external services with explicit
access rules.
> - Zapier, Arcade, Composio Connect, and Executor already have setup
and runtime support.
> - Their experimental switch still blocks discovery and setup by
default.
> - This pull request removes those gates and the Settings toggle.
> - Users can connect these providers without enabling an experiment.

## Linked Issues or Issue Description

Refs #13755. Refs #13941.

**What existing behavior does this improve?**
Apps browsing, inline setup, and agent connection search for the four
MCP aggregators.

**Current behavior**
An instance must enable the MCP aggregators experiment before users or
agents can start setup.

**Proposed behavior**
All four providers are available by default on local and managed
instances. Old stored and managed values still parse but cannot disable
them.

## What Changed

- Remove the aggregator gates from Apps, inline setup, server setup, and
agent search.
- Remove the Settings toggle and its UI hook.
- Retain the old setting key only for upgrade compatibility. Normalize
it to true and ignore managed overrides, as Apps already does.
- Replace opt-in fixtures with default-on coverage. Test old false
values, all four setup flows, provider choice, and the removed toggle.
- Update current connector guidance and remove the opt-in from the
runner acceptance fixture.

## Verification

- 306 focused tests passed across eight files: shared remote MCP
contracts; server remote MCP lifecycle, aggregator fallback, settings
normalization, and managed overlay; UI Apps browsing, setup, and
experimental settings.
- Server and UI TypeScript checks passed.
- UI token gates and `git diff --check` passed.
- The full local suite was not run, per the maintainer's instruction.
All 54 CI checks passed; two checks were skipped. One unrelated
workspace-preview readiness timeout passed on one failed-shard retry.
- The setup fixtures use simulated MCP responses. This change does not
claim new live provider acceptance.

## Risks

- Existing instances now show all four providers, even if the old flag
was false. This is intentional.
- External provider choice, credentials, company isolation, agent
grants, and tool policies still apply. Showing a connector does not
authorize an external account.
- No data migration is required. The compatibility key keeps old managed
configuration documents valid.
- Historical Zapier live acceptance remains incomplete in the existing
evidence report. The maintainer explicitly requested the default-on
rollout for all four existing providers; the report records that scoped
exception.

## Model Used

OpenAI GPT-6 (`gpt-6-astra`) through Codex, with reasoning, repository
tools, and test execution. The context window size is not exposed in
this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-24 17:31:32 -05:00
1 parent aa8fc86331
commit 8781f06a87
28 files changed
+86 -163

No files matched your search

@@ -2,7 +2,7 @@
September 21, 2026
Composio now has one Paperclip catalog method: direct MCP. Enable **Settings → Experimental → MCP aggregators**, then connect with Composio Connect or an externally configured MCP session URL and headers. The existing Access → Connect setup, OAuth, permissions, per-action Test, and gateway execution are unchanged. Connect underlying apps in Composio; Paperclip does not create per-app child connections.
Composio now has one Paperclip catalog method: direct MCP. Connect with Composio Connect or an externally configured MCP session URL and headers. The existing Access → Connect setup, OAuth, permissions, per-action Test, and gateway execution are unchanged. Connect underlying apps in Composio; Paperclip does not create per-app child connections.
The project API-key method, toolkit-management API routes, Services tab, connected-account synchronization, and session-minting broker have been removed. There is no automatic credential or grant migration.
+2 -2
View File
@@ -2211,8 +2211,8 @@ direct provider request only when a persisted message from the responsible human
proves that choice. An agent-supplied query alone does not count. Unclear or missing
message evidence falls back to a question naming that provider and None; alternatives
remain a provider-choice question. New human consent must postdate any saved decline
or different choice. Native administrative restrictions still cannot be bypassed. With
`enableMcpAggregators` enabled, a missing built-in match can return eligible
or different choice. Native administrative restrictions still cannot be bypassed. A
missing built-in match can return eligible
Composio, Arcade, Executor, and Zapier routes in that order. Search itself makes no
provider requests and starts no authorization.
+1 -1
View File
@@ -2,7 +2,7 @@
Retrieved 2026-09-21 from the providers’ own sites for the independent connector
design review. Brand-library membership does not publish a catalog connector.
The four connectors are available for setup only when the MCP aggregators experimental flag is enabled.
The four connectors are available for setup by default. No experimental flag is required.
Zapier and Composio reuse the existing reviewed local marks.
| File | Source | SHA-256 |
@@ -2,13 +2,15 @@
Test environment: an isolated development worktree with a fresh database and organization. No production data was cloned. The API served the compiled UI with browser-reachable OAuth callbacks. Detailed account and local-instance evidence remains in a private acceptance report.
**This PR delivers default-off experimental support at the maintainer's request. General provider acceptance remains incomplete: Zapier needs its generated credential entered before live validation can finish.** Three providers have real browser and real agent proof. Simulations are recorded separately below. The maintainer's subsequent delivery instruction was to put these connectors behind an experimental MCP aggregators flag and open a PR with passing checks. The Zapier gap is an explicit limitation of that experimental scope, not a claim that the connector playbook's full provider acceptance is complete. Complete that acceptance before promoting the feature out of experimental status.
The initial delivery was experimental. MCP aggregators are now available by default at the maintainer's request. The historical live evidence below is unchanged: three providers have real browser and real agent proof, while Zapier still lacks complete live acceptance. Removing the setup gate does not supply that missing proof. Simulations are recorded separately below.
## Experimental rollout
## Default-on rollout — 2026-09-24
Setup is behind **Settings → Experimental → MCP aggregators** (`enableMcpAggregators`). It defaults off on self-hosted and managed instances. When off, all four fresh catalog entries are hidden and direct setup, reconnect setup, and OAuth-start requests are rejected server-side. Existing connections keep running and remain available for management. The legacy Composio API-key broker and child connections have since been retired; see [Composio broker retirement](COMPOSIO-BROKER-RETIREMENT.md). An in-progress OAuth callback may complete; the flag does not revoke already issued credentials or grants.
Zapier, Arcade, Composio Connect, and Executor are available by default on self-hosted and managed instances. No experimental setting is required for catalog discovery, setup, reconnect, OAuth, or agent connection search. The retired `enableMcpAggregators` setting is ignored, including saved `false` values. Connection authorization, agent access, tool permissions, and external-provider choice still apply. The legacy Composio API-key broker and child connections remain retired; see [Composio broker retirement](COMPOSIO-BROKER-RETIREMENT.md).
Focused regression tests cover flag defaults, persistence, managed metadata, cached catalog visibility, all four direct setup routes, and server-side rejection before network/credential writes.
The maintainer explicitly requested removal of the experimental gate for all four existing connectors. This rollout follows that request as a scoped exception to the playbook's live-acceptance prerequisite; it does not change the general requirement for new connectors. Zapier's missing live proof remains recorded below and is not a passing acceptance result.
Focused regression tests cover default-on settings, ignored stored and managed opt-outs, cached catalog visibility, all four direct and inline setup routes, successful server setup and discovery, agent fallback with provider choice, and removal of the Settings toggle. The former flag-off rejection test was replaced; there is no longer a flag-based setup rejection before network or credential writes.
## Live results
+4 -5
View File
@@ -124,12 +124,11 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
selfHostedDefault: false,
},
enableMcpAggregators: {
title: "MCP aggregators",
description:
"Show experimental Zapier, Arcade, Composio Connect, and Executor setup. Existing MCP connections keep running when hidden.",
title: "MCP aggregators (compatibility)",
description: "Deprecated compatibility key. MCP aggregators are always enabled; stored and managed values are ignored.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
cloudDefault: true,
selfHostedDefault: true,
},
enablePipelines: {
title: "Pipelines",
@@ -6,11 +6,11 @@ import { instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSc
import { INSTANCE_FEATURE_CATALOG } from "./feature-catalog.js";
describe("independent remote MCP connectors", () => {
it("requires an explicit MCP aggregators opt-in for self-hosted and managed instances", () => {
expect(instanceExperimentalSettingsSchema.parse({}).enableMcpAggregators).toBe(false);
expect(patchInstanceExperimentalSettingsSchema.parse({ enableMcpAggregators: true })).toEqual({ enableMcpAggregators: true });
it("defaults the retired compatibility setting on and accepts older configs", () => {
expect(instanceExperimentalSettingsSchema.parse({}).enableMcpAggregators).toBe(true);
expect(patchInstanceExperimentalSettingsSchema.parse({ enableMcpAggregators: false })).toEqual({ enableMcpAggregators: false });
expect(patchInstanceExperimentalSettingsSchema.parse({})).not.toHaveProperty("enableMcpAggregators");
expect(INSTANCE_FEATURE_CATALOG.enableMcpAggregators).toMatchObject({ tier: "managed", cloudDefault: false, selfHostedDefault: false });
expect(INSTANCE_FEATURE_CATALOG.enableMcpAggregators).toMatchObject({ tier: "managed", cloudDefault: true, selfHostedDefault: true });
});
for (const [provider, methodKey] of Object.entries(REMOTE_MCP_CONNECTOR_METHODS)) {
it(`${provider} has its own catalog and accepts URL plus explicit credentials`, () => {
+1 -1
View File
@@ -71,7 +71,7 @@ export interface InstanceExperimentalSettings {
enableApps: boolean;
/** Exposes chat connector setup and Board surfaces; existing delivery continues when hidden. */
enableChatConnectors: boolean;
/** Exposes MCP aggregator setup; existing connections keep running when hidden. */
/** @deprecated Compatibility key only. MCP aggregators are always enabled. */
enableMcpAggregators: boolean;
/** Show experimental memory connection setup. Existing connections remain usable. */
enableMemoryConnectors: boolean;
+2 -1
View File
@@ -52,7 +52,8 @@ export const instanceExperimentalSettingsSchema = z.object({
// configs continue to load during upgrades.
enableApps: z.boolean().default(true),
enableChatConnectors: z.boolean().default(false),
enableMcpAggregators: z.boolean().default(false),
// Compatibility only: old stored and managed values must still parse.
enableMcpAggregators: z.boolean().default(true),
enableMemoryConnectors: z.boolean().default(false),
enablePipelines: z.boolean().default(false),
enableCases: z.boolean().default(false),
@@ -22,7 +22,6 @@ import {
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -142,9 +141,6 @@ const support = await getEmbeddedPostgresTestSupport();
await db
.delete(toolApplications)
.where(eq(toolApplications.companyId, claims.company_id));
await instanceSettingsService(db).updateExperimental({
enableMcpAggregators: true,
});
}
async function selectProvider(
option: string,
@@ -439,22 +435,12 @@ const support = await getEmbeddedPostgresTestSupport();
}),
).rejects.toMatchObject({ status: 403 });
});
it("gates fallback and direct aggregator requests behind the experiment", async () => {
it("offers fallback and direct aggregator requests without an experimental opt-in", async () => {
await resetQuestions();
await instanceSettingsService(db).updateExperimental({
enableMcpAggregators: false,
});
const result = await connectionIntentService(db).search(
claims,
"hubspot",
);
expect(result.results.some((item) => item.source === "aggregator")).toBe(
false,
);
expect(result.providerQuestion).toBeUndefined();
await expect(
connectionIntentService(db).request(claims, "composio"),
).rejects.toMatchObject({ status: 422 });
const result = await connectionIntentService(db).search(claims, "hubspot");
expect(result.results.some((item) => item.source === "aggregator")).toBe(true);
expect(result.providerQuestion).toBeDefined();
await expect(connectionIntentService(db).request(claims, "composio")).resolves.toMatchObject({ state: "needs_user_action" });
});
it("reuses an allowed selected provider without claiming the underlying app is ready", async () => {
await resetQuestions();
@@ -33,7 +33,6 @@ import { issueThreadInteractionService } from "../services/issue-thread-interact
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { materializeNativeInteractionResponses } from "../services/native-runtime/native-interaction-bridge.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -680,7 +679,6 @@ describeEmbeddedPostgres("connectionIntentService", () => {
});
it("preserves an authorizing card through comments and later runs", async () => {
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true });
const service = connectionIntentService(db);
const first = await service.request(claims, "zapier");
await service.updatePhase(first.interactionId!, "authorizing", claims.responsible_user_id);
@@ -42,7 +42,6 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { toolAccessService } from "../services/tool-access.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { ComposioApiError, type ComposioClient } from "../services/composio.js";
import { createComposioSessionManager } from "../services/composio-session-manager.js";
import { createToolGatewayService } from "../services/tool-gateway.js";
@@ -565,7 +564,6 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
});
it("keeps a generated Zapier URL attached to the curated Zapier identity", async () => {
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true });
const secretUrl = "https://mcp.zapier.com/api/v1/connect?token=zapier-secret";
const publicUrl = "https://mcp.zapier.com/api/v1/connect";
const company = await createCompany(db);
@@ -82,6 +82,15 @@ describe("applyManagedExperimentalOverlay", () => {
});
describe("instanceSettingsService managed overlay", () => {
it.each([{}, { enableMcpAggregators: false }])("keeps aggregators on with legacy stored and managed values: %j", async (stored) => {
const { db } = stubDb(settingsRow({ ...stored, enableChatConnectors: true }));
const service = instanceSettingsService(db, { runtimeEnv: managedEnv(JSON.stringify({
v: 1, mode: "cloud", catalogVersion: "legacy", features: { enableMcpAggregators: false }, plugins: { autoInstall: [] },
})) });
expect(await service.getExperimental()).toMatchObject({ enableMcpAggregators: true, enableChatConnectors: true, managedKeys: {} });
expect(await service.updateExperimental({ enableMcpAggregators: false })).toMatchObject({ experimental: { enableMcpAggregators: true, enableChatConnectors: true } });
});
it("persists chat connector opt-in and reads it back after service reconstruction", async () => {
const row = settingsRow({});
const { db, persistedSets } = stubDb(row);
@@ -41,9 +41,9 @@ describe("instance settings service", () => {
enableStreamlinedLeftNavigation: true,
enableStreamlinedUi: true,
enableApps: true,
enableMcpAggregators: true,
enableAgentChat: false,
enableChatConnectors: false,
enableMcpAggregators: false,
enableMemoryConnectors: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, heartbeatRuns, issues, toolCatalogEntries, toolConnectionInstalls, toolInvocations, companies, companyMemberships, createDb, toolConnections, toolPolicies, toolProfileEntries } from "@paperclipai/db";
import { agents, heartbeatRuns, issues, toolCatalogEntries, toolConnectionInstalls, toolInvocations, companies, companyMemberships, instanceSettings, createDb, toolConnections, toolPolicies, toolProfileEntries } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { toolAccessService } from "../services/tool-access.js";
@@ -23,7 +23,6 @@ describe("remote connector lifecycle", () => {
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key"));
fixture = await startEmbeddedPostgresTestDatabase("mcp-connectors-test-");
db = createDb(fixture.connectionString);
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true });
});
afterAll(async () => { await fixture?.cleanup(); vi.unstubAllEnvs(); if (keyDir) await rm(keyDir, { recursive: true, force: true }); });
async function company() {
@@ -51,28 +50,28 @@ describe("remote connector lifecycle", () => {
});
return { service, requests, add: () => { added = true; }, remove: () => { removed = true; }, restore: () => { removed = false; } };
}
it("defaults MCP aggregators off and rejects direct setup without provider requests or credential writes", async () => {
expect(normalizeExperimentalSettings({}).enableMcpAggregators).toBe(false);
it.each([undefined, false])("allows all aggregator setup with stored setting %s", async (legacyValue) => {
await db.delete(instanceSettings);
if (legacyValue !== undefined) await db.insert(instanceSettings).values({ experimental: { enableMcpAggregators: legacyValue } });
expect(normalizeExperimentalSettings({ enableMcpAggregators: legacyValue }).enableMcpAggregators).toBe(true);
expect((await instanceSettingsService(db).getExperimental()).enableMcpAggregators).toBe(true);
const org = await company(); const remote = remoteFixture();
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: false });
try {
const app = express();
app.use((req, _res, next) => { req.actor = { type: "board", userId: actor.actorId, source: "local_implicit", isInstanceAdmin: true }; next(); });
app.use("/api", toolAccessRoutes(db, { paperclipCloudConnector: null }));
const gallery = await request(app).get(`/api/companies/${org.id}/tools/gallery`);
expect(gallery.status).toBe(200);
expect(gallery.body.apps.some((entry: { slug: string }) => ["zapier", "arcade", "composio", "executor"].includes(entry.slug))).toBe(false);
expect(gallery.body.apps.some((entry: { slug: string }) => entry.slug === "notion")).toBe(true);
for (const [galleryKey, connectionMethodKey] of [["zapier", "generated-url"], ["arcade", "mcp"], ["composio", "mcp"], ["executor", "mcp"]]) {
await expect(remote.service.connectGalleryApp(org.id, { galleryKey, connectionMethodKey, saveDraft: true }, actor))
.rejects.toMatchObject({ status: 403, details: { code: "mcp_aggregators_disabled" } });
}
await expect(remote.service.preflightGalleryAppMetadata("composio", "mcp"))
.rejects.toMatchObject({ status: 403 });
expect(remote.requests).toHaveLength(0);
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, org.id))).toHaveLength(0);
} finally {
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true });
const app = express();
app.use((req, _res, next) => { req.actor = { type: "board", userId: actor.actorId, source: "local_implicit", isInstanceAdmin: true }; next(); });
app.use("/api", toolAccessRoutes(db, { paperclipCloudConnector: null }));
const gallery = await request(app).get(`/api/companies/${org.id}/tools/gallery`);
expect(gallery.status).toBe(200);
expect(gallery.body.apps.map((entry: { slug: string }) => entry.slug)).toEqual(expect.arrayContaining(["zapier", "arcade", "composio", "executor", "notion"]));
expect(gallery.body.apps.some((entry: { slug: string }) => entry.slug === "mem0")).toBe(false);
for (const [galleryKey, connectionMethodKey, link] of [
["zapier", "generated-url", "https://mcp.zapier.com/api/v1/connect?token=fixture-secret"],
["arcade", "mcp", "https://api.arcade.dev/mcp/fixture"],
["composio", "mcp", "https://connect.composio.dev/mcp"],
["executor", "mcp", "https://example.com/mcp"],
]) {
const connected = await remote.service.connectGalleryApp(org.id, { galleryKey, connectionMethodKey, link, authMode: "none" }, actor);
expect(connected.catalog).toHaveLength(3);
expect(connected.connectionId).toBeTruthy();
}
});
it.each([
@@ -176,9 +176,6 @@ describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
"paperclip-lifecycle-telemetry-",
);
db = createDb(tempDb.connectionString);
await instanceSettingsService(db).updateExperimental({
enableMcpAggregators: true,
});
}, 30_000);
afterEach(async () => {
@@ -811,14 +811,14 @@ describeEmbeddedPostgres("tool access service", () => {
process.env.PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL?.trim();
if (externalDatabaseUrl) {
db = createDb(externalDatabaseUrl);
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true, enableMemoryConnectors: true });
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
return;
}
tempDb = await startEmbeddedPostgresTestDatabase(
"paperclip-tool-access-service-",
);
db = createDb(tempDb.connectionString);
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true, enableMemoryConnectors: true });
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
}, 20_000);
afterEach(async () => {
+2 -3
View File
@@ -4,7 +4,6 @@ import { agents, companies, connectionGrants, issueThreadInteractions, toolConne
import { and, eq, or } from "drizzle-orm";
import {
APP_STORE_DEFINITIONS,
isRemoteMcpConnectorId,
isMemoryConnectorId,
GITHUB_CONNECTOR_PROFILES,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
@@ -816,7 +815,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
? await options.paperclipCloudConnector.getCapabilities()
: [];
const vercelConnect = vercelConnectIntegrationStatus();
const { enableMcpAggregators, enableMemoryConnectors } = await instanceSettingsService(db).getExperimental();
const { enableMemoryConnectors } = await instanceSettingsService(db).getExperimental();
res.json({
capabilities: await describeConnectionCreateCapabilities(req, companyId),
credentialSources: {
@@ -832,7 +831,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
: "Vercel Connect setup is disabled on this Paperclip instance.",
},
},
apps: APP_STORE_DEFINITIONS.filter((app) => (enableMcpAggregators || !isRemoteMcpConnectorId(app.slug)) && (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) =>
apps: APP_STORE_DEFINITIONS.filter((app) => (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) =>
appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles)
),
});
+1 -5
View File
@@ -35,7 +35,6 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { toolAccessService } from "./tool-access.js";
import { instanceSettingsService } from "./instance-settings.js";
import { captureRunIdentity } from "./run-identity.js";
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
@@ -343,7 +342,6 @@ export function connectionIntentService(db: Db) {
async function search(claims: ConnectionRunClaims, query: string, options: { retryProviderChoice?: boolean } = {}): Promise<ConnectionsSearchResult> {
const { run, agent, issue } = await loadRunContext(claims);
const aggregatorsEnabled = (await instanceSettingsService(db).getExperimental()).enableMcpAggregators;
const normalized = query.trim().toLocaleLowerCase();
const tokens = normalized.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
const inventory = await connectionInventory(run.companyId);
@@ -353,7 +351,6 @@ export function connectionIntentService(db: Db) {
sourceSlugForConnection(connection, inventory.applicationsById)?.startsWith("connection:")
&& connection.status !== "archived").map((connection) => `connection:${connection.id}`)];
for (const service of services) {
if (!aggregatorsEnabled && isRemoteMcpConnectorId(service)) continue;
let app;
try { app = await resolveService(service, run.companyId, run.responsibleUserId!, agent.id); }
catch (error) { if (service.startsWith("connection:") && (error as { status?: number }).status === 404) continue; throw error; }
@@ -401,7 +398,7 @@ export function connectionIntentService(db: Db) {
const explicitConsent = explicit && await hasExplicitProviderRequest(run.companyId, issue.id, run.responsibleUserId!, targetService, explicit.provider, previous[0]);
if (exact.length && (!explicitConsent || exact.some(({ item }) => item.state === "unavailable"))) return directSearchResult(query, exact.map(({ item }) => item));
const alternatives: ConnectionSearchResultItem[] = [];
if (aggregatorsEnabled && /^[a-z0-9][a-z0-9-]{0,79}$/.test(targetService)) {
if (/^[a-z0-9][a-z0-9-]{0,79}$/.test(targetService)) {
for (const provider of AGGREGATOR_PRIORITY) {
// Broad execute/search descriptions are not evidence of app support. Only a
// namespaced action (or an explicitly listed Executor integration) qualifies.
@@ -563,7 +560,6 @@ export function connectionIntentService(db: Db) {
upstreamService = { slug: route.targetService, name: selected.aggregator.targetName, selectionInteractionId: options.selectionInteractionId };
serviceSlug = route.provider;
}
if (isRemoteMcpConnectorId(serviceSlug) && !(await instanceSettingsService(db).getExperimental()).enableMcpAggregators) throw unprocessable("Experimental MCP aggregators are disabled");
const app = await resolveService(serviceSlug, context.run.companyId, context.run.responsibleUserId!, context.agent.id, options.purpose);
if (!app.available || app.methods.length === 0) {
throw unprocessable(`Connection service ${serviceSlug} is not available`);
+5 -5
View File
@@ -233,8 +233,8 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
// Apps graduated from Experimental. Ignore historical off values while
// continuing to accept the compatibility key in stored settings.
enableApps: true,
enableMcpAggregators: true,
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
enableMcpAggregators: parsed.data.enableMcpAggregators ?? false,
enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false,
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
@@ -276,8 +276,8 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableStreamlinedLeftNavigation: true,
enableStreamlinedUi: true,
enableApps: true,
enableMcpAggregators: true,
enableChatConnectors: false,
enableMcpAggregators: false,
enableMemoryConnectors: false,
enablePipelines: false,
enableCases: false,
@@ -334,9 +334,9 @@ export function applyManagedExperimentalOverlay(
for (const [key, value] of Object.entries(managedConfig.features) as Array<
[ManagedExperimentalFeatureKey, boolean]
>) {
// Existing Cloud stack configs may still carry enableApps. Accept the
// document during rollout, but never let the retired flag disable Apps.
if (key === "enableApps") continue;
// Existing Cloud stack configs may still carry retired flags. Accept the
// document during rollout, but never let retired flags disable Apps or MCP aggregators.
if (key === "enableApps" || key === "enableMcpAggregators") continue;
next[key] = value;
managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY };
}
+5 -11
View File
@@ -11869,17 +11869,11 @@ export function toolAccessService(
return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`;
}
async function assertExperimentalConnectorSetupEnabled(provider: unknown, method: unknown, existing = false) {
async function assertExperimentalConnectorSetupEnabled(provider: unknown, existing = false) {
if (!existing && isMemoryConnectorId(provider)
&& !(await instanceSettingsService(db).getExperimental()).enableMemoryConnectors) {
throw forbidden("Enable memory connectors in Settings → Experimental to set up this connection", { code: "memory_connectors_disabled" });
}
if (isRemoteMcpConnectorMethod(provider, method)
&& !(await instanceSettingsService(db).getExperimental()).enableMcpAggregators) {
throw forbidden("Enable MCP aggregators in Settings → Experimental to set up this connection", {
code: "mcp_aggregators_disabled",
});
}
}
async function connectGalleryApp(
@@ -12030,7 +12024,7 @@ export function toolAccessService(
? connectionMethodFor(galleryEntry, inferredMethodKey)
: null;
const remoteMcpConnector = isRemoteMcpConnectorMethod(galleryEntry?.slug, method?.key);
await assertExperimentalConnectorSetupEnabled(galleryEntry?.slug, method?.key, Boolean(
await assertExperimentalConnectorSetupEnabled(galleryEntry?.slug, Boolean(
input.reconnectConnectionId && requestedResumeConnection?.status !== "draft"
&& requestedResumeConnection?.config.sourceTemplateKey === galleryEntry?.slug,
));
@@ -13960,7 +13954,7 @@ export function toolAccessService(
): Promise<ToolConnectionHealthCheckResult> {
const connection = await getConnectionRow(connectionId, companyId);
assertSupportedConnection(connection);
await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey, connection.status !== "draft");
await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft");
if (connection.status === "archived")
throw conflict("Archived app connections cannot be reconnected");
if (connection.credentialSource === "vercel_connect") {
@@ -14139,7 +14133,7 @@ export function toolAccessService(
): Promise<ToolOAuthStartResult> {
let connection = await getConnectionRow(connectionId, companyId);
assertSupportedConnection(connection);
await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey, connection.status !== "draft");
await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft");
if (connection.status === "archived")
throw conflict("Archived app connections cannot start sign in");
const sourceTemplateKey =
@@ -16636,7 +16630,7 @@ export function toolAccessService(
if (!app || app.availability?.available === false)
throw notFound("App not found");
const method = connectionMethodFor(app, methodKey);
await assertExperimentalConnectorSetupEnabled(app.slug, method.key);
await assertExperimentalConnectorSetupEnabled(app.slug);
if (method.transport !== "mcp_remote" || !method.defaults?.serverUrl) {
throw unprocessable(
"This app method does not use a hosted remote MCP endpoint",
-1
View File
@@ -646,7 +646,6 @@ export async function runEverydayFlow(input: Input) {
authenticated: true,
});
if (providerChoice || nativeProviderCase) {
await api.patch("/api/instance/settings/experimental", {enableMcpAggregators:true});
if (caseId === "provider-second") aggregatorFixture = await setupAggregatorFixture(api, fixtures.company.id, fixtures.agent.id, `CONTACTS_${nonce}`);
const state = await api.get<{connections:Row[]}>(`/api/companies/${fixtures.company.id}/tools/connections`);
initialConnections = state.connections.map(c=>c.id);
@@ -1,6 +1,5 @@
import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup";
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled";
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
import { ConnectionChoiceList } from "./ConnectionChoiceList";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
@@ -539,7 +538,6 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) {
const [searchParams] = useSearchParams();
const params = useParams<{ appKey?: string }>();
const { selectedCompanyId } = useCompany();
const aggregators = useMcpAggregatorsEnabled();
const memory = useMemoryConnectorsEnabled();
const interactionId = props.interactionId || searchParams.get("intent") || undefined;
const source = props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey");
@@ -562,8 +560,6 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) {
}
if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault"
&& isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) {
if (!aggregators.loaded) return <p className="p-6 text-sm text-muted-foreground">Loading connection settings…</p>;
if (!aggregators.enabled) return <p role="status" className="p-6 text-sm text-muted-foreground">Enable MCP aggregators in Settings → Experimental to set up this connection.</p>;
return <RemoteMcpProductionSetup key={`${interactionId || "page"}:${provider}`} {...props} interactionId={interactionId} providerId={provider} connection={existing.data} />;
}
return <StandardConnectionSetupFlow {...props} />;
-12
View File
@@ -1,12 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { queryKeys } from "@/lib/queryKeys";
/** Default-off setup gate. Existing connections and their grants remain usable. */
export function useMcpAggregatorsEnabled() {
const query = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
return { enabled: !query.isError && query.data?.enableMcpAggregators === true, loaded: query.isFetched };
}
@@ -78,8 +78,8 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableStreamlinedLeftNavigation: true,
enableStreamlinedUi: true,
enableApps: true,
enableMcpAggregators: true,
enableChatConnectors: false,
enableMcpAggregators: false,
enableMemoryConnectors: false,
enablePipelines: false,
enableCases: false,
@@ -225,17 +225,10 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
}
});
it("defaults MCP aggregators off and persists an explicit toggle in both directions", async () => {
it("does not offer a retired MCP aggregators toggle", async () => {
await renderPage();
const selector = 'button[aria-label="Toggle MCP aggregators experimental setting"]';
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false");
expect(container.textContent).toContain("Existing MCP connections keep running.");
for (const enabled of [true, false]) {
await act(() => container.querySelector<HTMLButtonElement>(selector)!.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableMcpAggregators: enabled });
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled));
}
expect(container.querySelector('button[aria-label="Toggle MCP aggregators experimental setting"]')).toBeNull();
expect(container.textContent).not.toContain("MCP aggregators");
});
it("defaults chat connectors off and persists an explicit toggle in both directions", async () => {
@@ -1062,7 +1055,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => {
it("keeps only permitted controls in alphabetical order, including when hidden features are enabled", async () => {
setWorktreeRuntimeMeta(true);
const visible = new Set(["enableExternalObjects", "enableMcpAggregators", "enableSimplifiedEnglishInteractions"]);
const visible = new Set(["enableExternalObjects", "enableMemoryConnectors", "enableSimplifiedEnglishInteractions"]);
await renderPage(
INSTANCE_FEATURE_KEYS.filter((key) => !visible.has(key)).map((key) => `instance.experimental.${key}`),
{
@@ -1078,7 +1071,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => {
expect([...container.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([
"Enable External Objects",
"MCP aggregators",
"Memory connectors",
"Simplified English Interactions",
]);
expect([...container.querySelectorAll("section h2")].map((heading) => heading.textContent)).toEqual([
@@ -432,18 +432,6 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle first task plan proposal experimental setting"
/>
<ExperimentalToggleCard
title="MCP aggregators"
description="Connect Zapier, Arcade, Composio Connect, and Executor through their MCP servers."
footnote="Turning this off hides setup for these connectors. Existing MCP connections keep running."
checked={experimentalQuery.data?.enableMcpAggregators === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableMcpAggregators: checked })}
disabled={toggleMutation.isPending}
settingKey="enableMcpAggregators"
managed={managedKeys.enableMcpAggregators}
ariaLabel="Toggle MCP aggregators experimental setting"
/>
<ExperimentalToggleCard
title="Memory connectors"
description="Connect Mem0, Zep, Supermemory, Cognee, and Honcho for long-term memory and context."
+4 -10
View File
@@ -323,7 +323,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
}
it.each(["zapier", "arcade", "composio", "executor"])("inline aggregator %s collects the endpoint and completes only for the requester", async (provider) => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onComplete = vi.fn();
const popup = vi.spyOn(window, "open").mockReturnValue(null);
const connection = { id: "conn-inline", status: "draft", credentialPolicy: "per_user", authKind: "api_key" };
@@ -359,7 +358,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it.each(["arcade", "composio", "executor"])("inline aggregator %s binds OAuth to the task and retries the same draft", async (provider) => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onComplete = vi.fn();
const onPhaseChange = vi.fn();
const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
@@ -394,7 +392,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it("inline aggregator saves and resumes a draft without storing credentials in browser storage", async () => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onCancel = vi.fn();
const connection = { id: "conn-inline-draft", status: "draft", credentialPolicy: "per_user", authKind: "none", config: { url: "https://provider.example/mcp", sourceTemplateKey: "zapier", connectionMethodKey: "generated-url" } };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [] });
@@ -419,7 +416,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it("inline aggregator reuses an eligible account without changing its access", async () => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
const onUseExisting = vi.fn().mockResolvedValue(undefined);
await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="composio" requestedAgentId="agent-1" interactionId="intent-inline" existingConnections={[{ id: "existing", applicationId: "app", name: "Existing Composio", status: "active", enabled: true }]} onUseExisting={onUseExisting} />);
await act(async () => buttonContaining("Existing Composio")!.click());
@@ -445,10 +441,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).not.toContain("Enable memory connectors");
});
it.each(["zapier", "arcade", "composio", "executor"])("blocks direct %s setup while MCP aggregators are off", async (provider) => {
it.each(["zapier", "arcade", "composio", "executor"])("opens direct %s setup with default settings", async (provider) => {
mockSearch.value = `source=${provider}`;
await render();
expect(container.textContent).toContain("Enable MCP aggregators");
expect(container.textContent).not.toContain("Enable MCP aggregators");
await passAccessStep();
expect(container.textContent).toContain("MCP server URL");
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
@@ -456,7 +454,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it.each(["arcade", "composio", "executor"])("explains a failed %s OAuth return and retries the same saved draft", async (provider) => {
const draft = { id: "conn-oauth-draft", companyId: "company-1", status: "draft", authKind: "oauth", credentialPolicy: "shared", config: { sourceTemplateKey: provider, connectionMethodKey: "mcp", url: "https://example.com/mcp" } };
mockSearch.value = `source=${provider}&resume=${draft.id}&oauth=failed&code=oauth_callback_failed&error_description=untrusted-provider-message`;
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
getConnectionMock.mockResolvedValue(draft);
connectAppMock.mockResolvedValue({ connectionId: draft.id, connection: draft, catalog: [], auth: { kind: "oauth" } });
await render();
@@ -471,7 +468,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("explains a declined Composio OAuth return without discarding the draft", async () => {
mockSearch.value = "source=composio&resume=conn-oauth-draft&oauth=denied&code=oauth_authorization_denied";
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
getConnectionMock.mockResolvedValue({ id: "conn-oauth-draft", status: "draft", authKind: "oauth", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp", url: "https://connect.composio.dev/mcp" } });
await render();
await vi.waitFor(() => expect(container.textContent).toContain("Connection cancelled. Your setup details are preserved"));
@@ -485,7 +481,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
])("retains the %s identity when returning to Access after an OAuth return", async (credentialPolicy, identityLabel, asCurrentUser) => {
const draft = { id: "conn-oauth-draft", status: "draft", authKind: "oauth", credentialPolicy, config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp", url: "https://connect.composio.dev/mcp" } };
mockSearch.value = `source=composio&resume=${draft.id}&oauth=denied`;
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
getConnectionMock.mockResolvedValue(draft);
connectAppMock.mockResolvedValue({ connectionId: draft.id, connection: draft, catalog: [], auth: { kind: "oauth" } });
await render();
@@ -2830,7 +2825,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it("gives Zapier the shared credential and agent access opener", async () => {
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
mockSearch.value = "source=zapier";
listGalleryMock.mockResolvedValueOnce({
apps: [
+3 -12
View File
@@ -219,23 +219,14 @@ describe("Connectors landing page", () => {
for (const slug of ["zep", "supermemory", "cognee", "honcho"]) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
});
it("hides cached MCP aggregators until enabled and preserves saved MCP connections", async () => {
it("shows all MCP aggregators by default and ignores cached legacy opt-outs", async () => {
const providers = ["zapier", "arcade", "composio", "executor"];
listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) });
const client = await renderBrowse();
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: true }); });
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: false }); });
await flushReact();
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
await act(() => {
client.setQueryData(queryKeys.tools.connections("company-1"), { connections: [connection({ id: "saved", applicationId: "saved-app", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp" }, transport: "mcp_remote" })] });
client.setQueryData(queryKeys.tools.applications("company-1"), { applications: [application({ id: "saved-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: false });
});
await flushReact();
expect(container.textContent).toContain("Composio");
for (const slug of ["zapier", "arcade", "executor"]) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
});
it("defaults to tools-only GitHub and hides chat-only catalog and existing chat accounts", async () => {
-4
View File
@@ -19,7 +19,6 @@ import {
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
import {
getAppDefinitionForUrl,
isRemoteMcpConnectorId,
isMemoryConnectorId,
getAppStoreDefinition,
isToolConnectionAttentionHealth,
@@ -28,7 +27,6 @@ import {
import { useNavigate } from "@/lib/router";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled";
import { appCopyFor } from "@/lib/app-gallery-copy";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
@@ -284,7 +282,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
const { pushToast } = useToast();
const { selectedCompanyId } = useCompany();
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
const { enabled: mcpAggregatorsEnabled } = useMcpAggregatorsEnabled();
const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled();
const { setBreadcrumbs } = useBreadcrumbs();
const [query, setQuery] = useState("");
@@ -368,7 +365,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
(galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]
).filter((entry) => {
if (!memoryConnectorsEnabled && isMemoryConnectorId(appDefinitionSlug(entry))) return false;
if (!mcpAggregatorsEnabled && isRemoteMcpConnectorId(appDefinitionSlug(entry))) return false;
const definition = getAppStoreDefinition(appDefinitionSlug(entry));
return (
chatConnectorsEnabled ||