mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat(connections): let live discovery outrank a pinned ownership mode
Asana and Linear were pinned to customer-owned OAuth clients on the strength of an Aug-26 research snapshot, so both asked for a client ID and secret from a developer console before the first sign-in. Re-probed both servers on 2026-09-28: each advertises a registration endpoint and issues a client on request, and Linear also advertises client-ID metadata documents. The snapshot was simply out of date. So stop treating the catalog as the last word. A curated OAuth method still earns a dynamic registration when the provider's own metadata advertises one; ownershipModes stays as the manual fallback. A stale entry now costs a console detour at worst instead of silently outranking what the server just said about itself. Asana's registration is localhost-only — it refuses a hosted redirect URI — so its catalog text says that rather than promising one click everywhere. Linear accepts both. When the client-ID fields are genuinely required, the screen now says whose limitation it is, so the extra work reads as the exception it is rather than as that connector's normal path. Also fixes a tsc error from the previous commit: renderDefaults is an optional prop, not a nullable one. Tests: AppsConnect 165/165, connections 38/38, shared 24/24. The customer-owned-client case moved to Box, which is still genuinely pinned; Asana gained a test asserting it now reaches the handoff with no form in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b63aef204c
commit
a3cdba956f
7 files changed
+73
-36
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{required ? "Your OAuth app" : "Use your own OAuth app"}
|
||||
{required ? `${entry.name} needs its own OAuth app` : "Use your own OAuth app"}
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
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.`}
|
||||
</p>
|
||||
{method.consoleLinks?.register ? (
|
||||
<a
|
||||
|
||||
@@ -52,6 +52,7 @@ const GITHUB_MANAGED = {
|
||||
};
|
||||
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => 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<HTMLInputElement>("#curated-oauth-client-id")!;
|
||||
const clientSecret = container.querySelector<HTMLInputElement>("#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 () => {
|
||||
|
||||
Reference in new issue
Block a user