diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 0dd10f30ca7..cce4f4fe937 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -274,7 +274,7 @@ describe("AppDefinition catalog", () => { "google-workspace-search", ]), ); - expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(48); + expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(49); expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([ "g2", "vercel", @@ -427,15 +427,15 @@ describe("AppDefinition catalog", () => { expect(channel("slack")?.guidanceMd).toContain("reactions"); expect(channel("slack")?.guidanceMd).toContain("direct messages"); }); - it("keeps a complete, unique, dated evidence ledger for all 51 researched MCP providers", () => { + it("keeps a complete, unique, dated evidence ledger for all 52 researched MCP providers", () => { // Ledger-wide date reflects the last full re-verification (2026-08-26); // later provider additions carry their own research evidence, but // bumping the shared date would overstate freshness for the other providers. expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26"); - expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(51); + expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(52); expect( new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)), - ).toHaveProperty("size", 51); + ).toHaveProperty("size", 52); for (const entry of SELF_SERVE_MCP_RESEARCH.entries) { expect(new URL(entry.docsUrl).protocol).toBe("https:"); expect(new URL(entry.serverUrl).protocol).toBe("https:"); @@ -573,7 +573,11 @@ describe("AppDefinition catalog", () => { (field) => field.key === "readOnly", )?.defaultValue, ).toBe(false); - expect(method("asana")?.ownershipModes).toEqual(["customer"]); + // Asana and Linear both advertise dynamic client registration and issue + // clients on request (verified live 2026-09-28), so neither needs an + // operator-registered OAuth app. "customer" stays as the manual fallback. + expect(method("asana")?.ownershipModes).toEqual(["dcr", "customer"]); + expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]); expect(method("zapier")).toMatchObject({ key: "generated-url", auth: "none", diff --git a/packages/shared/src/app-definitions/asana.json b/packages/shared/src/app-definitions/asana.json index d144ead8bed..a4fa23d5b35 100644 --- a/packages/shared/src/app-definitions/asana.json +++ b/packages/shared/src/app-definitions/asana.json @@ -21,22 +21,20 @@ "transport": "mcp_remote", "auth": "oauth", "ownershipModes": [ + "dcr", "customer" ], - "whenToUse": "Register an OAuth app with Asana, then enter its client ID and secret.", + "whenToUse": "Sign in with Asana. Paperclip registers its own OAuth client automatically.", "defaults": { "serverUrl": "https://mcp.asana.com/v2/mcp" }, - "guidanceMd": "Connect Asana in the browser. Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported.", + "guidanceMd": "Connect Asana in the browser. Paperclip registers its own OAuth client with Asana's MCP server, so a local Paperclip needs no developer-console setup. Asana only accepts localhost callbacks, so a hosted deployment falls back to entering your own OAuth app.", "riskTier": "S3", - "label": "Use your own OAuth app", + "label": "Sign in with Asana", "consoleLinks": { "register": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", "docs": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server" - }, - "warnings": [ - "Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported." - ] + } } ] } diff --git a/packages/shared/src/app-definitions/linear.json b/packages/shared/src/app-definitions/linear.json index 28d77bf3700..9dc209229f6 100644 --- a/packages/shared/src/app-definitions/linear.json +++ b/packages/shared/src/app-definitions/linear.json @@ -20,6 +20,7 @@ "transport": "mcp_remote", "auth": "oauth", "ownershipModes": [ + "dcr", "customer" ], "whenToUse": "Use the provider-hosted connection for the quickest setup.", @@ -32,7 +33,7 @@ "write" ] }, - "guidanceMd": "Register a Linear OAuth app and add Paperclip's redirect URI before connecting.", + "guidanceMd": "Connect Linear for issues and projects. Paperclip registers its own OAuth client with Linear's MCP server, so no developer-console setup is needed.", "riskTier": "S2", "requiredResourceFilters": [ "workspace", diff --git a/packages/shared/src/self-serve-mcp-research.json b/packages/shared/src/self-serve-mcp-research.json index 0efcf21e2c4..366d6d763f9 100644 --- a/packages/shared/src/self-serve-mcp-research.json +++ b/packages/shared/src/self-serve-mcp-research.json @@ -40,7 +40,8 @@ { "slug": "supabase", "name": "Supabase", "wave": 2, "status": "self_serve", "docsUrl": "https://supabase.com/docs/guides/ai-tools/mcp", "serverUrl": "https://mcp.supabase.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Supabase account; use a development project and review write actions before connecting production data.", "riskTier": "S4" }, { "slug": "ticket-tailor", "name": "Ticket Tailor", "wave": 2, "status": "self_serve", "docsUrl": "https://developers.tickettailor.com/docs/mcp/authentication/", "serverUrl": "https://mcp.tickettailor.ai/mcp", "authMode": "dcr", "prerequisite": "A Ticket Tailor account; the provider may request an API key during its hosted authorization prompt.", "riskTier": "S3" }, - { "slug": "asana", "name": "Asana", "wave": 3, "status": "self_serve", "docsUrl": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", "serverUrl": "https://mcp.asana.com/v2/mcp", "authMode": "customer_oauth", "prerequisite": "Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported.", "riskTier": "S3" }, + { "slug": "asana", "name": "Asana", "wave": 3, "status": "self_serve", "docsUrl": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", "serverUrl": "https://mcp.asana.com/v2/mcp", "authMode": "dcr", "prerequisite": "An active Asana account. Re-verified 2026-09-28: mcp.asana.com advertises a registration endpoint and issues clients for http://localhost callbacks, so a local Paperclip registers its own OAuth client. Asana refuses non-localhost redirect URIs, so a hosted deployment still registers an app in Asana's developer console.", "riskTier": "S3" }, + { "slug": "linear", "name": "Linear", "wave": 3, "status": "self_serve", "docsUrl": "https://linear.app/docs/mcp", "serverUrl": "https://mcp.linear.app/mcp", "authMode": "dcr_cimd", "prerequisite": "An active Linear workspace. Verified 2026-09-28: mcp.linear.app advertises a registration endpoint and client-ID metadata documents, so Paperclip registers its own OAuth client.", "riskTier": "S2" }, { "slug": "box", "name": "Box", "wave": 3, "status": "self_serve", "docsUrl": "https://support.box.com/hc/en-us/articles/43847256139923-Managing-Box-MCP-Servers", "serverUrl": "https://mcp.box.com", "authMode": "customer_oauth", "prerequisite": "A Box administrator creates the OAuth integration and enables AI access.", "riskTier": "S3" }, { "slug": "mem0", "name": "Mem0", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.mem0.ai/platform/mem0-mcp", "serverUrl": "https://mcp.mem0.ai/mcp/", "authMode": "api_key", "prerequisite": "A Mem0 API key; the live server currently requires the slash-normalized endpoint.", "riskTier": "S3" }, { "slug": "pagerduty", "name": "PagerDuty", "wave": 3, "status": "self_serve", "docsUrl": "https://support.pagerduty.com/main/docs/pagerduty-mcp-server", "serverUrl": "https://mcp.pagerduty.com/mcp", "authMode": "api_key", "prerequisite": "A PagerDuty API token; choose the regional endpoint that hosts the account.", "riskTier": "S4" }, diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 50962c9ba57..72523e2df35 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -9949,6 +9949,13 @@ export function toolAccessService( * connection may register once — and only once — protected-resource and * authorization-server discovery actually produced a metadata document; an * endpoint that merely returned a 401 does not earn a registration. + * + * A curated method pinned to customer-owned clients still earns a registration + * when the provider's own metadata advertises one. `ownershipModes` is a + * point-in-time research snapshot, and providers add dynamic registration + * without telling us; live discovery is the better evidence of the two, so a + * stale catalog entry costs the operator a console detour rather than silently + * outranking what the server just said about itself. */ function canRegisterOAuthClientDynamically( connection: typeof toolConnections.$inferSelect, @@ -9956,10 +9963,9 @@ export function toolAccessService( galleryEntry: AppDefinition | null, ): boolean { if (galleryEntry) { - return connectionMethodForConnection( - galleryEntry, - connection, - ).ownershipModes.includes("dcr"); + const method = connectionMethodForConnection(galleryEntry, connection); + if (method.ownershipModes.includes("dcr")) return true; + return method.auth === "oauth" && Boolean(endpoints.registrationUrl); } return ( connection.transport === "mcp_remote" && Boolean(endpoints.metadataUrl) diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 4f0bf2dfc81..7b8ce01ad43 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -2357,7 +2357,7 @@ function StandardConnectionSetupFlow({ setGoogleSheetsError(null); }} submitting={connectMutation.isPending} - renderDefaults={renderConnectionDefaults} + renderDefaults={renderConnectionDefaults ?? undefined} // Back returns to the connector gallery for new, resumed, and // reconnected accounts. Cancel is the separate exit to the app list. onBack={backToGallery} @@ -3864,10 +3864,17 @@ function OAuthClientFields({
- {required ? "Your OAuth app" : "Use your own OAuth app"} + {required ? `${entry.name} needs its own OAuth app` : "Use your own OAuth app"}
+ {/* + When these fields are required it is a provider limitation, not a step + Paperclip chose to add. Say so, so the extra work reads as the + exception it is rather than as this connector's normal path. + */}

- Register Paperclip's callback URI in {entry.name}, then enter the customer-owned client details. + {required + ? `${entry.name} does not let Paperclip register itself automatically, so this connector needs an OAuth app you create. Add the callback URL below in ${entry.name}, then paste the client details back here.` + : `Register Paperclip's callback URI in ${entry.name}, then enter the customer-owned client details.`}

{method.consoleLinks?.register ? ( app.slug === "notion")!; const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!; +const BOX = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "box")!; const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!; const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!; const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!; @@ -933,14 +934,17 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it("collects customer-owned OAuth client details for a curated manual OAuth app", async () => { - listGalleryMock.mockResolvedValue({ apps: [ASANA] }); - mockParams.appKey = "asana"; + listGalleryMock.mockResolvedValue({ apps: [BOX] }); + mockParams.appKey = "box"; await render(); await passAccessStep(); - expect(container.textContent).toContain("Your OAuth app"); - expect(container.textContent).toContain("Open Asana app settings"); - expect(container.textContent).not.toContain("Create an Asana MCP OAuth app"); + expect(container.textContent).toContain("needs its own OAuth app"); + expect(container.textContent).toContain("Open Box app settings"); + // The extra work reads as the provider's limitation, not as Box's normal path. + expect(container.textContent).toContain( + "does not let Paperclip register itself automatically", + ); expect(container.textContent).toContain("Paperclip callback URL"); expect(container.textContent).toContain( "http://localhost:3000/api/tools/oauth/callback", @@ -950,8 +954,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { const clientId = container.querySelector("#curated-oauth-client-id")!; const clientSecret = container.querySelector("#curated-oauth-client-secret")!; await act(async () => { - setInputValue(clientId, "asana-client-id"); - setInputValue(clientSecret, "asana-client-secret"); + setInputValue(clientId, "box-client-id"); + setInputValue(clientSecret, "box-client-secret"); }); await flushReact(); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); @@ -962,15 +966,31 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ - galleryKey: "asana", + galleryKey: "box", connectionMethodKey: "mcp-own-oauth", oauthClient: { - clientId: "asana-client-id", - clientSecret: "asana-client-secret", + clientId: "box-client-id", + clientSecret: "box-client-secret", }, })); }); + it("gives Asana the one-click path now that its server advertises registration", async () => { + listGalleryMock.mockResolvedValue({ apps: [ASANA] }); + mockParams.appKey = "asana"; + await render(); + await flushReact(); + + // No console detour: the connect screen is the handoff, not a form. + expect(container.textContent).not.toContain("needs its own OAuth app"); + expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); + const primary = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.trim().startsWith("Continue to"), + ); + expect(primary).toBeTruthy(); + expect(primary?.disabled).toBe(false); + }); + it("submits the Postman access mode selected on the setup screen", async () => { listGalleryMock.mockResolvedValue({ apps: [POSTMAN] }); mockParams.appKey = "postman"; @@ -1089,7 +1109,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { ); expect(buttonByText("Connect with Paperclip")?.closest(".rounded-xl")?.classList.contains("border-border")).toBe(true); expect(container.textContent).not.toContain("Required once for managed Google sign-in."); - expect(container.textContent).not.toContain("Your OAuth app"); + expect(container.textContent).not.toContain("needs its own OAuth app"); await act(async () => { buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -1501,7 +1521,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); // Change auth methods too, including apps with only one capability. expect(container.textContent).not.toContain("Connect with Paperclip"); - expect(container.textContent).not.toContain("Your OAuth app"); + expect(container.textContent).not.toContain("needs its own OAuth app"); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); const customerAuth = buttonByText("Use your own Google OAuth app"); expect(customerAuth).toBeDefined(); @@ -1510,7 +1530,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { customerAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); - expect(container.textContent).toContain("Your OAuth app"); + expect(container.textContent).toContain("needs its own OAuth app"); expect(container.textContent).toContain("Client ID"); expect(buttonByText("Continue to sign in")?.disabled).toBe(true); const managedAuth = buttonByText("Use Paperclip instead"); @@ -1523,7 +1543,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { managedAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); - expect(container.textContent).not.toContain("Your OAuth app"); + expect(container.textContent).not.toContain("needs its own OAuth app"); await act(async () => { buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -1568,7 +1588,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false"); expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account"); expect(container.textContent).toContain("Review requirements"); - expect(container.textContent).toContain("Your OAuth app"); + expect(container.textContent).toContain("needs its own OAuth app"); }); it("renders Google Calendar as one minimal, unboxed setup screen", async () => {