From 4ece2107b8ba205c180ab4a99cd313a074009e76 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:12:45 -0600 Subject: [PATCH] fix(connections): offer aggregator Slack sign-in on Cloud instead of a customer OAuth app (#19328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Connectors let people give agents tools, such as Slack, from the Connectors catalog or from an agent's inline "Connect" card > - On Paperclip Cloud, the only Slack path is "Slack needs its own OAuth app". The user must create a Slack app, paste a callback URL, and enter a Client ID and secret > - Managed Slack chat setup exists, but it is behind the experimental Chat connectors setting, which is off on Cloud. Composio's and Arcade's managed Slack sign-in exists, but the native Slack row hides it > - In an onboarding test on Cloud, a tester got this card from the onboarding agent, spent 7 minutes on it, and declined it. No Slack connection was made > - This pull request follows the existing Google Workspace pattern: on Cloud, a native connector whose every setup path needs a customer-created OAuth app yields new setup to the aggregators' managed sign-in, in the catalog and in agent connection discovery > - The benefit is that Cloud users can connect Slack with a sign-in instead of creating a Slack app ## Linked Issues or Issue Description **What happened?** On a Paperclip Cloud instance, the onboarding agent asked the user to connect Slack. Connectors > Slack > Connect showed "Slack needs its own OAuth app. Slack does not let Paperclip register itself automatically, so this connector needs an OAuth app you create." The form asks for a callback URL, a Client ID and a Client secret. It has no link to Slack app settings. The method description said "Use the provider-hosted connection for the quickest setup." The tester gave up. The original notes said that the cloud connector asked for an HTTPS server. That is not correct: no HTTPS or deployment-mode check is in this path. The real blocker is the customer OAuth app requirement. **Expected behavior** Slack can be connected from the supported Cloud flow without setup steps that only an operator can do. **Steps to reproduce** 1. On a Cloud instance with Chat connectors off (the Cloud default), open Connectors and search for "Slack". 2. Click Connect on Slack. The "needs its own OAuth app" form opens. 3. Or, let an agent call `connections_search("Slack")`. It returns native `slack` first, and its card opens the same form. **Additional context** Evidence: the Slack gallery entry on the Cloud instance had `ownershipAvailability` `platform_shared: false`, `platform_provisioned: false`, `customer: true`. `GET /api/companies/:id/chat-slack/setup-options` returned 403 "Chat connectors are disabled". ## What Changed - `packages/shared`: add `nativeConnectorRequiresCustomerOAuthApp(app, settings)`. It is true when every available setup path of a native connector needs a customer-created OAuth app: no Paperclip-managed client, no dynamic client registration, and no enabled chat setup. - `ui/src/pages/apps/Browse.tsx`: on Cloud, such a connector yields to aggregators, the same as the built-in Google connectors. The catalog shows the "Connect through Composio or Arcade" row, and its Connect button opens the provider choice. A saved native connection, a chat endpoint, Chat connectors on, a self-hosted instance, or no aggregator route keeps the native row. - `server/src/services/connection-intents.ts` and new `connection-native-yield.ts`: agent `connections_search` uses the same rule. On Cloud, it does not prefer native Slack for new setup, so the agent asks the provider-choice question (Composio, Arcade or Zapier). The rule applies the advertised Paperclip Cloud connector profiles, so apps with a managed Cloud client (for example Asana) do not yield. If the profiles cannot be read, the native connector stays. - `slack.json`: correct the Slack tool method copy, which said "quickest setup". Add a "register" console link to `https://api.slack.com/apps`, so the form now shows "Open Slack app settings". - Tests in all three layers. ## Verification Red/green. I committed the tests first (`2386fd83df`) and ran them against unchanged source code. **Red (before the fix):** ``` server › aggregator connection fallback › Slack on Paperclip Cloud × offers aggregator Slack routes while native Slack only supports a customer-created OAuth app AssertionError: expected [ 'slack', 'google-calendar', …(7) ] to deeply equal [ 'via:composio:slack', …(2) ] + "slack", "google-calendar", "google-docs", ... Tests 1 failed | 3 passed | 45 skipped (49) ui › Connectors landing page × offers aggregator Slack sign-in on Cloud while the built-in Slack connector needs a customer OAuth app AssertionError: expected 'SlackSend and read messages in your t…' to contain 'Connect through Composio or Arcade.' Received: "SlackSend and read messages in your team's channels.Connect" Tests 1 failed | 5 passed | 92 skipped (98) shared › Slack tool connection guidance × describes the customer-created Slack app honestly and links to Slack app settings AssertionError: expected 'Use the provider-hosted connection fo…' not to match /quickest/i Tests 1 failed | 3 passed (4) ``` **Green (after the fix):** ``` ✓ server › Slack on Paperclip Cloud › offers aggregator Slack routes while native Slack only supports a customer-created OAuth app ✓ server › Slack on Paperclip Cloud › keeps native Slack when Chat connectors provide its own setup path ✓ server › Slack on Paperclip Cloud › keeps native Slack for a company that already saved a Slack connection ✓ server › keeps native Slack on self-hosted instances, where operators register their own Slack app ✓ ui › offers aggregator Slack sign-in on Cloud while the built-in Slack connector needs a customer OAuth app ✓ ui › keeps the built-in Slack connector when Chat connectors offer its setup path ✓ ui › keeps the built-in Slack connector when the instance is self-hosted ✓ ui › keeps a saved built-in Slack connection on its native row on Cloud ✓ shared › describes the customer-created Slack app honestly and links to Slack app settings ✓ shared › flags Slack only while its chat setup is unavailable ✓ shared › does not flag connectors with a managed, dynamic-registration, or credential path ``` Neighbouring suites: - `packages/shared` (all): 98 files, 1060 tests passed - `ui` `src/pages/apps` + `src/features/connections`: 54 files, 980 tests passed - `server` `connection-aggregator-fallback` 49/49, `connection-intents-service`, `native-connection-baseline-fixture`, `runner-connection-eval-fixtures` passed. `satisfied-connection-intents` 52/52 passed when run alone. It had a suite-setup error only when three embedded-Postgres runs went in parallel. - `pnpm check:token-gates`: clean. `tsc --noEmit` shows no errors in changed files. Local runs show unrelated errors because the `paperclip-runner` workspace package is not built. CI runs the full typecheck. Still needs a live Slack workspace to prove: - Complete Slack sign-in through Composio or Arcade on a Cloud instance, and an agent tool call to Slack. - An agent run on Cloud that asks the provider-choice question for Slack and then connects through the selected aggregator. ## Risks - Behaviour change on Cloud only, for native connectors whose every path needs a customer-created OAuth app and that an aggregator serves. Today that is Slack (when Chat connectors are off), and Box and Xero if the aggregator catalog has them. On Cloud, these move to the aggregator route for new setup, the same as the built-in Google connectors. Saved connections keep their native row and discovery. - Cloud users who want their own Slack app can still open `/apps/connect?source=slack`, but the catalog row no longer links to it while the rule applies. - Self-hosted instances keep the native connector unchanged. - Follow-up, not in this PR: enable managed Slack chat on Cloud (Chat connectors Cloud default and `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED`). This is a flag and operations change. It needs the Cloud Slack broker to be proven first. When it is enabled, this rule stops yielding Slack automatically. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, browser inspection of a staging instance, file editing). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../shared/src/app-definitions/slack.json | 5 +- .../shared/src/connector-availability.test.ts | 34 +++++++++- packages/shared/src/connector-availability.ts | 39 ++++++++++- packages/shared/src/index.ts | 2 +- .../connection-aggregator-fallback.test.ts | 66 ++++++++++++++++++- server/src/services/connection-intents.ts | 31 +++++++-- .../src/services/connection-native-yield.ts | 13 ++++ ui/src/pages/apps/Browse.test.tsx | 61 ++++++++++++++++- ui/src/pages/apps/Browse.tsx | 27 +++++++- 9 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 server/src/services/connection-native-yield.ts diff --git a/packages/shared/src/app-definitions/slack.json b/packages/shared/src/app-definitions/slack.json index 208c7aaccb8..733de01cdee 100644 --- a/packages/shared/src/app-definitions/slack.json +++ b/packages/shared/src/app-definitions/slack.json @@ -22,7 +22,10 @@ "ownershipModes": [ "customer" ], - "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "whenToUse": "Give agents Slack tools through Slack's hosted MCP server. Slack requires an OAuth app you create in Slack app settings.", + "consoleLinks": { + "register": "https://api.slack.com/apps" + }, "defaults": { "serverUrl": "https://mcp.slack.com/mcp", "authorizationEndpoint": "https://slack.com/oauth/v2/authorize", diff --git a/packages/shared/src/connector-availability.test.ts b/packages/shared/src/connector-availability.test.ts index 7b7f6cbfac5..eea485b72e5 100644 --- a/packages/shared/src/connector-availability.test.ts +++ b/packages/shared/src/connector-availability.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { getConnectableAppDefinition } from "./app-definitions.js"; -import { isConnectorFeatureEnabled } from "./connector-availability.js"; +import { isConnectorFeatureEnabled, nativeConnectorRequiresCustomerOAuthApp } from "./connector-availability.js"; import { appDefinitionSchema } from "./validators/app-definition.js"; import { instanceExperimentalSettingsSchema } from "./validators/instance.js"; @@ -37,3 +37,35 @@ describe("connector availability", () => { } }); }); + +describe("Slack tool connection guidance", () => { + // BUG-06: Slack's MCP server has no dynamic client registration, so this method needs a + // customer-created Slack app. Its guidance must say so and link to Slack's app settings. + it("describes the customer-created Slack app honestly and links to Slack app settings", () => { + const method = getConnectableAppDefinition("slack")!.methods.find((candidate) => candidate.key === "mcp-oauth")!; + expect(method.whenToUse).not.toMatch(/quickest/i); + expect(method.consoleLinks?.register).toBe("https://api.slack.com/apps"); + }); +}); + +describe("native connectors that need a customer-created OAuth app", () => { + it("flags Slack only while its chat setup is unavailable", () => { + const slack = getConnectableAppDefinition("slack")!; + expect(nativeConnectorRequiresCustomerOAuthApp(slack, {})).toBe(true); + expect(nativeConnectorRequiresCustomerOAuthApp(slack, { enableChatConnectors: true })).toBe(false); + }); + + it("does not flag connectors with a managed, dynamic-registration, or credential path", () => { + // Asana declares a Paperclip Cloud managed method, so it is never flagged, + // even while that method's broker profile is unavailable. + const asana = getConnectableAppDefinition("asana")!; + expect(nativeConnectorRequiresCustomerOAuthApp(asana, {})).toBe(false); + expect(nativeConnectorRequiresCustomerOAuthApp({ ...asana, ownershipAvailability: { + platform_shared: true, platform_provisioned: false, customer: true, dcr: true, + } }, {})).toBe(false); + for (const slug of ["notion", "linear", "github"]) { + expect(nativeConnectorRequiresCustomerOAuthApp(getConnectableAppDefinition(slug)!, {}), slug).toBe(false); + } + expect(nativeConnectorRequiresCustomerOAuthApp(null, {})).toBe(false); + }); +}); diff --git a/packages/shared/src/connector-availability.ts b/packages/shared/src/connector-availability.ts index 1e1b5c0a340..82aec0dfb2e 100644 --- a/packages/shared/src/connector-availability.ts +++ b/packages/shared/src/connector-availability.ts @@ -1,4 +1,9 @@ -import type { AppDefinition } from "./types/app-definition.js"; +import { + connectionMethodAcceptsCustomerOAuthClient, + connectionMethodSupportsAutomaticOAuth, + getAvailableConnectionMethods, +} from "./app-definitions.js"; +import type { AppDefinition, ConnectionMethodDef } from "./types/app-definition.js"; import type { InstanceExperimentalSettings } from "./types/instance.js"; export function isConnectorFeatureEnabled( @@ -7,3 +12,35 @@ export function isConnectorFeatureEnabled( ): boolean { return !app?.experimentalFeatureKey || settings?.[app.experimentalFeatureKey] === true; } + +function channelMethodEnabled( + method: ConnectionMethodDef, + settings: Partial | null | undefined, +): boolean { + if (method.provider === "agentmail") return true; + if (method.provider === "github") return settings?.enableGitHubReviewBots === true; + return settings?.enableChatConnectors === true; +} + +/** + * True when every setup path of a native connector first needs the customer to + * create an OAuth app with the provider (no dynamic registration and no enabled + * chat setup). Catalog and agent discovery then offer equivalent MCP aggregator + * sign-in for new setup. Callers keep the native connector for saved connections + * and when no aggregator serves the app. A connector that declares a Paperclip + * Cloud managed method is never flagged: whether its broker profile is live is a + * runtime fact, and a failed or slow capability read must not change setup routes. + */ +export function nativeConnectorRequiresCustomerOAuthApp( + app: AppDefinition | null | undefined, + settings: Partial | null | undefined, +): boolean { + if (!app || !isConnectorFeatureEnabled(app, settings)) return false; + if (app.methods.some((method) => method.oauthStrategy === "paperclip_cloud_connector" + || method.oauthStrategy === "paperclip_id_connector")) return false; + const methods = getAvailableConnectionMethods(app).filter((method) => method.purpose !== "ai" + && (method.purpose !== "channel" || channelMethodEnabled(method, settings))); + return methods.length > 0 && methods.every((method) => method.purpose !== "channel" + && connectionMethodAcceptsCustomerOAuthClient(method) + && !connectionMethodSupportsAutomaticOAuth(method)); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d80124a0136..03fa88d7132 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -295,7 +295,7 @@ export { recommendedDefaultsForApp, resolveConnectionMethodServerUrl, } from "./app-definitions.js"; -export { isConnectorFeatureEnabled } from "./connector-availability.js"; +export { isConnectorFeatureEnabled, nativeConnectorRequiresCustomerOAuthApp } from "./connector-availability.js"; export { getAppDefinitionForApplicationKey } from "./connector-identity.js"; export { connectionSetupStateForApp, diff --git a/server/src/__tests__/connection-aggregator-fallback.test.ts b/server/src/__tests__/connection-aggregator-fallback.test.ts index 5dc08bae48a..1e98d0bce51 100644 --- a/server/src/__tests__/connection-aggregator-fallback.test.ts +++ b/server/src/__tests__/connection-aggregator-fallback.test.ts @@ -3,7 +3,7 @@ import { requestHumanInputAction } from "../../../packages/paperclip-runner/src/ import { questionSetToAskUserQuestionsPayload } from "@paperclipai/shared"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, authUsers, @@ -401,6 +401,70 @@ const support = await getEmbeddedPostgresTestSupport(); expect(result.providerQuestion).toBeUndefined(); expect(result.instruction).toContain("connection_request"); }); + // BUG-06: on Paperclip Cloud, the native Slack tool path requires a customer-created + // Slack OAuth app and managed Slack chat setup is behind the Chat connectors opt-in. + // Agents must offer the one-click aggregator routes instead of that card. + describe("Slack on Paperclip Cloud", () => { + const slackQuery = "Slack (team updates / notifications)"; + beforeAll(() => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "cloud-test-token"); }); + afterAll(() => { vi.unstubAllEnvs(); }); + it("offers aggregator Slack routes while native Slack only supports a customer-created OAuth app", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, slackQuery); + expect(result.results.map((item) => item.service)).toEqual([ + "via:composio:slack", + "via:arcade:slack", + "via:zapier:slack", + ]); + expect(result.providerQuestion?.prompt).toContain("Slack"); + }); + it("keeps native Slack when Chat connectors provide its own setup path", async () => { + await resetQuestions(); + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + try { + const result = await connectionIntentService(db).search(claims, slackQuery); + expect(result.results[0]).toMatchObject({ service: "slack", state: "available" }); + expect(result.providerQuestion).toBeUndefined(); + } finally { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false }); + } + }); + it("does not offer native Slack setup beside another app's native match", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "Find Notion and Slack tools"); + const services = result.results.map((item) => item.service); + expect(services).toEqual(expect.arrayContaining(["notion", "via:composio:slack"])); + expect(services).not.toContain("slack"); + }); + it("keeps Slack's aggregator routes in capability-only discovery", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "start and continue Paperclip work"); + const services = result.results.map((item) => item.service); + expect(services).toContain("via:composio:slack"); + expect(services).not.toContain("slack"); + // A route needs the provider-choice flow before connection_request. + expect(result.instruction).toContain("search its aggregator.targetService"); + }); + it("keeps native connectors that declare a Paperclip Cloud managed method", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "asana"); + expect(result.results[0]).toMatchObject({ service: "asana" }); + expect(result.providerQuestion).toBeUndefined(); + }); + it("keeps native Slack for a company that already saved a Slack connection", async () => { + await resetQuestions(); + await seedProvider("slack", "slack_send_message"); + const result = await connectionIntentService(db).search(claims, slackQuery); + expect(result.results[0]).toMatchObject({ service: "slack" }); + expect(result.providerQuestion).toBeUndefined(); + }); + }); + it("keeps native Slack on self-hosted instances, where operators register their own Slack app", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "Slack (team updates / notifications)"); + expect(result.results[0]).toMatchObject({ service: "slack", state: "available" }); + expect(result.providerQuestion).toBeUndefined(); + }); it("returns ranked verified routes and does not create questions or connections during search", async () => { await resetQuestions(); const result = await connectionIntentService(db).search( diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 9544e73b1c8..c6450199fef 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -55,6 +55,7 @@ import { type AiConnectionBinding, } from "@paperclipai/shared"; import { instanceSettingsService } from "./instance-settings.js"; +import { createNativeConnectorYield } from "./connection-native-yield.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js"; import { issueThreadInteractionService, type InteractionResolutionMutationOptions } from "./issue-thread-interactions.js"; @@ -463,6 +464,9 @@ export function connectionIntentService(db: Db) { const inventory = await connectionInventory(run.companyId); const candidates: Array<{ item: ConnectionSearchResultItem; score: number; nameScore: number }> = []; const authorizedCatalogs = new Map>>(); + // New setup of these native services is offered through aggregators instead. + const yieldsToAggregators = createNativeConnectorYield(settings); + const yieldingServices = new Set(); const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app) .filter(method => method.purpose !== "channel" || app.slug === "agentmail" || (method.provider === "github" ? settings.enableGitHubReviewBots : settings.enableChatConnectors)); @@ -507,6 +511,7 @@ export function connectionIntentService(db: Db) { const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name], `${app.description ?? ""} ${app.searchCapabilities} ${catalog.map(tool => `${tool.toolName} ${tool.description ?? ""}`).join(" ")}`); if (!score) continue; + if (definition && !matching.length && yieldsToAggregators(definition)) yieldingServices.add(service); const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, responsibleUserId: run.responsibleUserId!, serviceSlug: service, purpose: aiOnly ? "ai" : service === "agentmail" ? "channel" : undefined, inventory }); const denied = !aiOnly && !ready && matching.length > 0 && await administrativeDenial(run.companyId, agent.id, service, inventory); @@ -538,6 +543,18 @@ export function connectionIntentService(db: Db) { connectionId: ready?.id ?? null, }}); } + // A yielded native service is not offered for new setup when an aggregator + // route can serve it; its routes take its place in ranked results, so broad + // and capability-only searches still find the app. Without a route the + // native service remains the fallback. + const yieldedRoutes: typeof candidates = []; + for (const service of yieldingServices) { + const candidate = candidates.find(({ item }) => item.service === service); + const routes = candidate ? await aggregatorAlternatives(service, candidate.item.name, findAggregatorService(service)) : []; + if (!routes.length) continue; + candidates.splice(candidates.indexOf(candidate!), 1); + yieldedRoutes.push(...routes.map(item => ({ item, score: candidate!.score, nameScore: candidate!.nameScore }))); + } const publicMatches = searchAggregatorServices(preparedQuery); const bestMatches = publicMatches.filter(match => Math.floor(match.nameScore / 100) === Math.floor((publicMatches[0]?.nameScore ?? 0) / 100)); const namedPublicService = findAggregatorService(serviceQuery, publicMatches); @@ -566,8 +583,13 @@ export function connectionIntentService(db: Db) { // A typo match such as Notion must not hide the distinctly named app Motion. const exact = candidates.filter(({ item, nameScore }) => ((nameScore >= 200 && Math.floor(nameScore / 100) >= bestExternalTier) || item.service === publicService?.slug) && (!isRemoteMcpConnectorId(item.service) || (!publicService && !indexedMatches.length))); - const ranked = () => candidates.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)) + const ranked = () => [...candidates, ...yieldedRoutes].sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)) .slice(0, 40).map(({ item }) => item); + // Aggregator routes need a recorded provider choice before connection_request. + const rankedResult = (suggestions = false) => { + const results = ranked(); + return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, suggestions); + }; const targetService = publicService?.slug ?? (indexedMatches.length === 1 ? indexedMatches[0]!.slug : normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-")); // Indexed-only app labels must be identical when requests re-search the slug. const targetName = publicService?.name ?? targetService.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); @@ -581,7 +603,7 @@ export function connectionIntentService(db: Db) { aggregatorAlternatives(service.slug, service.name, service)))).flat(); if (otherRoutes.length) return discoverySuggestions([...ranked(), ...otherRoutes]); } - return directSearchResult(query, ranked()); + return rankedResult(); } async function aggregatorAlternatives(targetService: string, targetName: string, publicService?: AggregatorServiceDefinition) { const alternatives: ConnectionSearchResultItem[] = []; @@ -647,7 +669,7 @@ export function connectionIntentService(db: Db) { aggregatorAlternatives(service.slug, service.name, service)))).flat(); if (matches.length) return discoverySuggestions([...ranked(), ...matches]); } - if (!alternatives.length) return directSearchResult(query, ranked(), true); + if (!alternatives.length) return rankedResult(true); const question = aggregatorProviderQuestion(targetService, targetName, alternatives); const latest = previous[0]; if (latest && (latest.status === "pending" || !options.retryProviderChoice)) { @@ -668,7 +690,8 @@ export function connectionIntentService(db: Db) { instruction: "No matching built-in Paperclip connection was found. Ask the responsible user with providerQuestion exactly as returned (including its id, full prompt, and options). With native request_human_input, use interactionKind questions, continuationPolicy wake_assignee, and payload {version:1, questionSet:providerQuestionSet} exactly as returned. Otherwise use ask_user_questions with payload {version:1, questions:[providerQuestion]}. These are external services. Wait for the saved answer; then call connection_request with the selected service identifier and selectionInteractionId set to the answered question interaction ID. None for now means do not connect. Do not claim app access yet." }; function discoverySuggestions(results: ConnectionSearchResultItem[]): ConnectionsSearchResult { - return { version: 1, query, results: results.slice(0, 40), + const seen = new Set(); + return { version: 1, query, results: results.filter(item => !seen.has(item.service) && Boolean(seen.add(item.service))).slice(0, 40), instruction: "These are possible connection matches. Choose the relevant service and method using descriptions and purposes. For available or needs_user_action results, tool methods and AgentMail use connection_request to show the inline setup card; other channel or AI methods use setupPath. Use ready tools as installed; ready AI authentication applies to the next execution without reconnection. For an aggregator result, search its aggregator.targetService to obtain the provider-choice question and follow that instruction before requesting a connection. Respect unavailable states. Do not treat a search match as provider consent or app authorization." }; } } diff --git a/server/src/services/connection-native-yield.ts b/server/src/services/connection-native-yield.ts new file mode 100644 index 00000000000..fd24adc9dc9 --- /dev/null +++ b/server/src/services/connection-native-yield.ts @@ -0,0 +1,13 @@ +import { nativeConnectorRequiresCustomerOAuthApp, type AppDefinition, type InstanceExperimentalSettings } from "@paperclipai/shared"; +import { isCloudManagedInstance } from "./cloud-instance.js"; + +/** + * Decides, per discovery request, whether a native connector should yield new + * setup to MCP aggregators: on Paperclip Cloud, when every setup path needs a + * customer-created OAuth app. This is the same rule the Connectors catalog uses; + * it is synchronous and never contacts the Cloud broker. + */ +export function createNativeConnectorYield(settings: Partial) { + const cloudManaged = isCloudManagedInstance(); + return (app: AppDefinition): boolean => cloudManaged && nativeConnectorRequiresCustomerOAuthApp(app, settings); +} diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 3f73b43effc..95a5223cba9 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -233,10 +233,11 @@ describe("Connectors landing page", () => { vi.clearAllMocks(); }); - async function renderBrowse(allCatalog = true) { + async function renderBrowse(allCatalog = true, options: { cloud?: boolean } = {}) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + if (options.cloud) client.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } }); root = createRoot(container); await act(async () => { root.render( @@ -679,6 +680,64 @@ describe("Connectors landing page", () => { expect(container.querySelectorAll('[data-app-slug="hubspot"] button[aria-label*="third-party"]')).toHaveLength(2); }); + // BUG-06: on Paperclip Cloud, Slack's built-in tool method needs a customer-created OAuth + // app and, without the Chat connectors opt-in, there is no managed Slack path. Connect + // must offer the aggregators' one-click sign-in instead of the "needs its own OAuth app" form. + it("offers aggregator Slack sign-in on Cloud while the built-in Slack connector needs a customer OAuth app", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack"), getAppStoreDefinition("notion")] }); + await renderBrowse(true, { cloud: true }); + + const rows = container.querySelectorAll('[data-app-slug="slack"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).toContain("Connect through Composio or Arcade."); + await clickButton("Connect", rows[0]!); + expect(navigateMock).not.toHaveBeenCalledWith("/apps/connect?source=slack"); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Which service would you like to use?"); + }); + + it.each([ + ["Chat connectors offer its setup path", { enableChatConnectors: true }, true], + ["the instance is self-hosted", {}, false], + ])("keeps the built-in Slack connector when %s", async (_label, experimental, cloud) => { + experimentalMock.mockResolvedValue(experimental); + aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack")] }); + await renderBrowse(true, { cloud }); + + const rows = container.querySelectorAll('[data-app-slug="slack"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).not.toContain("Connect through Composio"); + }); + + it("keeps native Asana on Cloud when the gallery omits its unavailable managed method", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(indexedApp("Asana", ["composio", "arcade"])); + const asana = getAppStoreDefinition("asana")!; + // The gallery removes a Paperclip Cloud method whose broker profile is not advertised. + const galleryAsana = { ...asana, methods: asana.methods.filter((method) => !method.oauthStrategy) }; + listGalleryMock.mockResolvedValue({ apps: [galleryAsana] }); + await renderBrowse(true, { cloud: true }); + + const rows = container.querySelectorAll('[data-app-slug="asana"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).not.toContain("Connect through Composio"); + }); + + it("keeps a saved built-in Slack connection on its native row on Cloud", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "slack-app", name: "Slack", metadata: { sourceTemplateKey: "slack" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "slack-app", name: "Team Slack", transport: "mcp_remote", config: { sourceTemplateKey: "slack" } })] }); + await renderBrowse(true, { cloud: true }); + + const rows = container.querySelectorAll('[data-app-slug="slack"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).toContain("Team Slack"); + }); + it("paginates the catalog while keeping installed connectors above every page and resets on search", async () => { aggregatorCatalogMock.push(...Array.from({ length: 60 }, (_, index) => indexedApp(`Indexed App ${String(index).padStart(2, "0")}`))); listApplicationsMock.mockResolvedValue({ applications: [application()] }); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index f6fea3bc2a9..31ccbd6046c 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -40,11 +40,13 @@ import { GOOGLE_WORKSPACE_CONNECTOR_PROFILES, CONNECTABLE_APP_DEFINITIONS, normalizeConnectionQuery, + nativeConnectorRequiresCustomerOAuthApp, } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled"; import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; import { useConnectorAvailability } from "@/hooks/useConnectorAvailability"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; import { appCopyFor } from "@/lib/app-gallery-copy"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -377,6 +379,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); const connectorAvailability = useConnectorAvailability(); const googleWorkspaceConnectorEnabled = connectorAvailability.enabled(getConnectableAppDefinition("google-workspace")); + const cloudManaged = useCloudInstance()?.managed === true; const { setBreadcrumbs } = useBreadcrumbs(); const { status: connectivityStatus } = useConnectivity(); const [query, setQuery] = useState(""); @@ -777,8 +780,25 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne // Native definitions take precedence even when temporarily hidden or unavailable. // Public metadata never creates an application, account, or installed status. - const yieldsToAggregators = (slug: string) => !googleWorkspaceConnectorEnabled - && BUILT_IN_GOOGLE_CONNECTOR_SLUGS.has(slug) && !(rowsBySlug.get(slug)?.connections.length); + // On Cloud, a native connector whose every setup path needs a customer-created + // OAuth app (such as Slack without Chat connectors) yields new setup to the + // aggregators' managed sign-in. Saved native accounts keep their native row. + const hasAggregatorRoute = (row: ConnectorRowModel) => managedCatalog.some((app) => + [app.name, app.slug, ...app.aliases].some((alias) => + [row.slug, row.name].some((name) => aggregatorAppIdentity(alias) === aggregatorAppIdentity(name)))); + const yieldsToAggregators = (slug: string) => { + const row = rowsBySlug.get(slug); + if (row?.connections.length || row?.chatEndpoints.length) return false; + if (!googleWorkspaceConnectorEnabled && BUILT_IN_GOOGLE_CONNECTOR_SLUGS.has(slug)) return true; + // Decide from the declared methods: the gallery drops a Cloud managed method + // whose broker profile is unavailable, and that must not change setup routes. + const declaredMethods = getAppStoreDefinition(slug)?.methods ?? row?.entry?.methods; + return cloudManaged && Boolean(row?.entry) && hasAggregatorRoute(row!) + && nativeConnectorRequiresCustomerOAuthApp({ ...row!.entry!, methods: declaredMethods ?? [] }, { + enableChatConnectors: chatConnectorsEnabled, + enableGitHubReviewBots: githubEnabled, + }); + }; const nativeIdentities = new Set([...CONNECTABLE_APP_DEFINITIONS, ...(galleryQuery.data?.apps ?? [])] .filter((app) => !yieldsToAggregators(appDefinitionSlug(app))) .flatMap((app) => [appDefinitionName(app), appDefinitionSlug(app)].map(aggregatorAppIdentity))); @@ -800,7 +820,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne })); return [...rowsBySlug.values(), ...customRows, ...aggregatorRows] - .filter((row) => row.aggregatorApp || !LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) + .filter((row) => row.aggregatorApp || (!LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) && !yieldsToAggregators(row.slug)) || row.connections.length > 0 || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) .map((row) => ({ @@ -831,6 +851,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne gallery, galleryQuery.data, googleWorkspaceConnectorEnabled, + cloudManaged, upstreamAppsBySlug, ]);