diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md
index 950656cbf2..58bf3c5e9e 100644
--- a/doc/connections/CONNECTOR-PLAYBOOK.md
+++ b/doc/connections/CONNECTOR-PLAYBOOK.md
@@ -662,8 +662,16 @@ internal discussion; a task comment or agent progress update must not imply
that an external action occurred. Reuse existing task-feed components and
preserve one visible record per external event.
+**Keep setup to one screen.** The connect screen collects only what proves who
+the user is: a provider sign-in, a key, or an endpoint. It states the default
+access in one line, with **Change** for other choices, and does not add an
+access step. Pick the ranked default method instead of asking. Put scope,
+capability, and per-action choices on the Permissions tab after the connection.
+`connectionSetupStateForMethod` in `packages/shared` classifies each method as
+`instant`, `authorize`, `paste`, or `register`; the gallery verb comes from it.
+
**Make interactive Storybooks for setup and actual use.** Include the catalog
-card, access and credential steps, any agent-resource wizard, and the task
+card, the connect screen and its credential states, any agent-resource wizard, and the task
journeys after setup. Provide a clickable walkthrough plus focused stories for
important steps, loading, errors, and recovery. Use realistic fixtures and
clearly label simulated actions. Reuse production components as implementation
diff --git a/doc/connections/GITHUB.md b/doc/connections/GITHUB.md
index e118218623..161b709d36 100644
--- a/doc/connections/GITHUB.md
+++ b/doc/connections/GITHUB.md
@@ -19,12 +19,12 @@ repository and MCP URLs still resolve to the ordinary GitHub tool connection.
## Self-hosted setup
-The Access step uses **Continue** to open the local setup screen.
-**Continue to GitHub** on that screen starts the provider handoff. The first
-button does not imply that the browser is leaving Paperclip yet.
+The setup screen states the default access in one line, with **Change** for
+other choices. **Continue to GitHub** on that screen starts the provider
+handoff.
A self-hosted instance needs one Paperclip Cloud approval before its first
-managed connection. After approval, setup returns to step 2 and continues to
+managed connection. After approval, setup returns to the connect screen and continues to
GitHub without another instance approval or a service restart.
If an unapproved enrollment link expires, return to setup and select
diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts
index 72d9c53b12..adce8e88f1 100644
--- a/packages/shared/src/app-definitions.test.ts
+++ b/packages/shared/src/app-definitions.test.ts
@@ -262,7 +262,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",
@@ -426,15 +426,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:");
@@ -572,7 +572,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",
@@ -617,7 +621,7 @@ describe("AppDefinition catalog", () => {
APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0]
?.defaults?.scopesHint,
).toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]));
- it("defaults every new connection action to allowed", () => {
+ it("defaults every action, reads and writes, to allowed", () => {
for (const app of APP_DEFINITIONS)
for (const method of app.methods)
expect(recommendedDefaultsForApp(app, method.key)).toEqual({
diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts
index c347335d0c..83410b49bf 100644
--- a/packages/shared/src/app-definitions.ts
+++ b/packages/shared/src/app-definitions.ts
@@ -255,10 +255,12 @@ export function resolveConnectionMethodServerUrl(
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record {
// Keep the parameters in the public contract: callers resolve defaults for a
- // concrete app/method even though the initial policy is now uniform. This is
+ // concrete app/method even though the initial policy is uniform. This is
// an open default, not an approval bypass: connection finalization remains a
// configure-authorized, audited operation, and Ask first stays available as
// an operator-selected policy for any action after the connection is made.
+ // The connect flow lands on the Permissions tab so that choice is the very
+ // next screen (PAP-659: agents get full permissions unless someone narrows them).
void app;
void methodKey;
return {
diff --git a/packages/shared/src/app-definitions/asana.json b/packages/shared/src/app-definitions/asana.json
index 8376a35288..ecda6f4b60 100644
--- a/packages/shared/src/app-definitions/asana.json
+++ b/packages/shared/src/app-definitions/asana.json
@@ -21,25 +21,23 @@
"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",
"scopesHint": [
"default"
]
},
- "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 28d77bf370..9dc209229f 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/connection-setup-state.test.ts b/packages/shared/src/connection-setup-state.test.ts
new file mode 100644
index 0000000000..e4cc83af58
--- /dev/null
+++ b/packages/shared/src/connection-setup-state.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import {
+ CONNECTABLE_APP_DEFINITIONS,
+ getAvailableConnectionMethods,
+ getConnectableAppDefinition,
+} from "./app-definitions.js";
+import {
+ connectionSetupStateForApp,
+ connectionSetupVerbForApp,
+ type ConnectionSetupState,
+} from "./connection-setup-state.js";
+
+/**
+ * These assert against the shipped catalog rather than fixtures, because the
+ * claim being protected is about real connectors: the gallery card's verb has
+ * to match what the connect screen then asks for.
+ */
+describe("connectionSetupStateForApp", () => {
+ it("puts one-click OAuth connectors in the authorize state", () => {
+ for (const slug of ["notion", "linear", "sentry", "stripe", "jira", "asana"]) {
+ expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("authorize");
+ }
+ });
+
+ it("follows the instance's ownership availability rather than the catalog order", () => {
+ // Google and GitHub publish a Paperclip-managed method, but it is
+ // `platform_shared` and therefore unavailable until an operator configures
+ // the cloud connector. The state has to reflect what this instance can
+ // actually do, or the card promises one click and the screen shows a form.
+ const gmail = getConnectableAppDefinition("gmail")!;
+ expect(connectionSetupStateForApp(gmail)).toBe("register");
+ expect(
+ connectionSetupStateForApp({
+ ...gmail,
+ ownershipAvailability: { ...gmail.ownershipAvailability, platform_shared: true },
+ }),
+ ).toBe("authorize");
+ });
+
+ it("reserves register for providers that genuinely cannot issue a client", () => {
+ for (const slug of ["box", "xero"]) {
+ expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("register");
+ }
+ });
+
+ it("treats an API key as a paste, and says so on the card", () => {
+ for (const slug of ["honcho", "mem0", "openrouter"]) {
+ expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("paste");
+ expect(connectionSetupVerbForApp(getConnectableAppDefinition(slug)), slug).toBe("Add key");
+ }
+ });
+
+ it("reaches instant only when the catalog already knows the endpoint", () => {
+ // Composio and Context7 ship a fixed server URL and ask for nothing.
+ expect(connectionSetupStateForApp(getConnectableAppDefinition("composio"))).toBe("instant");
+ expect(connectionSetupStateForApp(getConnectableAppDefinition("context7"))).toBe("instant");
+ });
+
+ it("does not promise instant for a provider-generated URL", () => {
+ // Zapier, Arcade and Executor declare no server URL: the operator brings
+ // one. Calling these instant is the mistake this helper exists to prevent.
+ for (const slug of ["zapier", "arcade", "executor"]) {
+ expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("paste");
+ }
+ });
+
+ it("does not promise instant for a templated endpoint", () => {
+ // Shopify's URL has a {storeDomain} placeholder the operator fills in.
+ expect(connectionSetupStateForApp(getConnectableAppDefinition("shopify"))).toBe("paste");
+ });
+
+ it("defaults the AI providers to their subscription sign-in", () => {
+ for (const slug of ["anthropic", "openai", "xai"]) {
+ expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("authorize");
+ expect(connectionSetupVerbForApp(getConnectableAppDefinition(slug)), slug).toBe("Connect");
+ }
+ });
+
+ it("never says Add key for something that does not want a secret", () => {
+ for (const app of CONNECTABLE_APP_DEFINITIONS) {
+ if (connectionSetupVerbForApp(app) !== "Add key") continue;
+ const wantsSecret = getAvailableConnectionMethods(app)
+ .filter((method) => (method.purpose ?? "tool") !== "channel")
+ .some((method) => (method.credentialFields?.length ?? 0) > 0);
+ expect(wantsSecret, app.slug).toBe(true);
+ }
+ });
+
+ it("resolves a state for every connectable tool connector", () => {
+ const states = new Map();
+ for (const app of CONNECTABLE_APP_DEFINITIONS) {
+ const hasToolMethod = app.methods.some((method) => (method.purpose ?? "tool") !== "channel");
+ if (!hasToolMethod) continue;
+ states.set(app.slug, connectionSetupStateForApp(app));
+ }
+ expect([...states].filter(([, state]) => state === null)).toEqual([]);
+ });
+
+ it("returns null rather than guessing for an unknown app", () => {
+ expect(connectionSetupStateForApp(null)).toBeNull();
+ });
+
+ it("survives a gallery row that carries no method list", () => {
+ // Rows are assembled from the gallery, live connections and custom
+ // endpoints, so a row can reach a card without methods. Throwing here would
+ // take the whole connectors page down with it.
+ const partial = { slug: "mystery", name: "Mystery" } as never;
+ expect(connectionSetupStateForApp(partial)).toBeNull();
+ expect(connectionSetupVerbForApp(partial)).toBe("Connect");
+ });
+});
diff --git a/packages/shared/src/connection-setup-state.ts b/packages/shared/src/connection-setup-state.ts
new file mode 100644
index 0000000000..becd04545f
--- /dev/null
+++ b/packages/shared/src/connection-setup-state.ts
@@ -0,0 +1,112 @@
+import {
+ connectionMethodAcceptsCustomerOAuthClient,
+ connectionMethodRequiresConfiguration,
+ connectionMethodSupportsAutomaticOAuth,
+ getAvailableConnectionMethods,
+ getRecommendedConnectionMethod,
+} from "./app-definitions.js";
+import type { AppDefinition, ConnectionMethodDef } from "./types/app-definition.js";
+
+/**
+ * The four states every connect screen lands in (PAP-659, "one shell, four
+ * states").
+ *
+ * The point of naming them here rather than in the wizard is that two surfaces
+ * have to agree: the gallery card decides its verb from the state, and the
+ * connect screen decides its body from the same state. When each re-derived it
+ * from raw method flags they drifted — a card offering "Connect" for something
+ * that then demanded a pasted URL is exactly the inconsistency the ticket is
+ * about.
+ *
+ * - `instant` nothing to supply: connect resolves entirely from the catalog.
+ * - `authorize` one click, then the provider's own consent screen.
+ * - `paste` one thing the operator has to bring: a key, a URL, a tenant.
+ * - `register` the provider cannot issue a client, so one must be registered
+ * in its console first. A recovery state, never a normal step.
+ */
+export type ConnectionSetupState = "instant" | "authorize" | "paste" | "register";
+
+/**
+ * True when the catalog already knows the endpoint. A `serverUrlTemplate` does
+ * not count: its placeholders are the operator's to fill, which is a paste.
+ */
+function hasResolvedEndpoint(method: ConnectionMethodDef): boolean {
+ if (method.transport !== "mcp_remote") return true;
+ if (method.defaults?.serverUrlTemplate) return false;
+ return Boolean(method.defaults?.serverUrl);
+}
+
+export function connectionSetupStateForMethod(
+ method: ConnectionMethodDef | null | undefined,
+): ConnectionSetupState | null {
+ if (!method) return null;
+ if (method.auth === "oauth") {
+ // An AI subscription signs in through the adapter's own device/OAuth flow.
+ // No client is ever registered, so `register` would be a false detour.
+ if (method.transport === "runtime_auth") return "authorize";
+ if (connectionMethodSupportsAutomaticOAuth(method)) return "authorize";
+ // A customer-owned client is only a *register* state when the provider
+ // offers nothing better. Live discovery can still overrule the catalog at
+ // connect time, which is why the flow re-resolves rather than trusting this.
+ if (connectionMethodAcceptsCustomerOAuthClient(method)) return "register";
+ return "authorize";
+ }
+ if (method.auth === "none") {
+ return connectionMethodRequiresConfiguration(method) || !hasResolvedEndpoint(method)
+ ? "paste"
+ : "instant";
+ }
+ return "paste";
+}
+
+/**
+ * The default tool method for an app, or null.
+ *
+ * Gallery rows are not guaranteed to carry a method list: the display entry is
+ * assembled from several sources, and a row can stand for a connection whose
+ * catalog entry has since been withdrawn. Resolving that to null — rather than
+ * throwing inside a card render — is the difference between a missing verb and
+ * a blank connectors page.
+ */
+function defaultToolMethod(
+ app: AppDefinition | null | undefined,
+ methodKey?: string | null,
+): ConnectionMethodDef | null {
+ if (!app || !Array.isArray(app.methods)) return null;
+ const methods = getAvailableConnectionMethods(app).filter(
+ (method) => (method.purpose ?? "tool") !== "channel",
+ );
+ return methodKey
+ ? methods.find((candidate) => candidate.key === methodKey) ?? null
+ : getRecommendedConnectionMethod(methods);
+}
+
+export function connectionSetupStateForApp(
+ app: AppDefinition | null | undefined,
+ methodKey?: string | null,
+): ConnectionSetupState | null {
+ return connectionSetupStateForMethod(defaultToolMethod(app, methodKey));
+}
+
+/**
+ * The gallery card's verb (PAP-659 C4).
+ *
+ * Only a method that genuinely wants a secret says so. "Add key" on a connector
+ * that actually wants a pasted URL would be worse than the generic verb, so a
+ * paste state without credential fields keeps "Connect" — the operator still
+ * lands on one screen with one field, which the verb does not need to restate.
+ */
+export function connectionSetupVerbForMethod(
+ method: ConnectionMethodDef | null | undefined,
+): "Connect" | "Add key" {
+ if (!method) return "Connect";
+ const wantsSecret = (method.credentialFields?.length ?? 0) > 0;
+ return connectionSetupStateForMethod(method) === "paste" && wantsSecret ? "Add key" : "Connect";
+}
+
+export function connectionSetupVerbForApp(
+ app: AppDefinition | null | undefined,
+ methodKey?: string | null,
+): "Connect" | "Add key" {
+ return connectionSetupVerbForMethod(defaultToolMethod(app, methodKey));
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 78ded88c4d..92466095b9 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -333,6 +333,13 @@ export {
recommendedDefaultsForApp,
resolveConnectionMethodServerUrl,
} from "./app-definitions.js";
+export {
+ connectionSetupStateForApp,
+ connectionSetupStateForMethod,
+ connectionSetupVerbForApp,
+ connectionSetupVerbForMethod,
+ type ConnectionSetupState,
+} from "./connection-setup-state.js";
export { APP_DEFINITIONS } from "./app-definitions.generated.js";
export * from "./google-workspace-connectors.js";
export * from "./github-connectors.js";
diff --git a/packages/shared/src/self-serve-mcp-research.json b/packages/shared/src/self-serve-mcp-research.json
index 0efcf21e2c..366d6d763f 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/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts
index 44121fb4c6..7fc5d90010 100644
--- a/server/src/__tests__/tool-access-service.test.ts
+++ b/server/src/__tests__/tool-access-service.test.ts
@@ -10031,6 +10031,64 @@ describeEmbeddedPostgres("tool access service", () => {
).toHaveLength(2);
});
+ it("registers Linear against its MCP authorization server instead of the pinned console endpoints", async () => {
+ vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
+ vi.stubEnv("PAPERCLIP_TOOL_OAUTH_LINEAR_CLIENT_ID", "");
+ vi.stubEnv("PAPERCLIP_TOOL_OAUTH_LINEAR_CLIENT_SECRET", "");
+ vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
+ vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
+ const company = await createCompany(db);
+ const userId = `linear-owner-${randomUUID()}`;
+ await grantBoardUser(db, company.id, userId, [], "owner");
+ const app = createRouteApp(
+ db,
+ boardSessionActor(company.id, "owner", userId),
+ );
+ const fetched: string[] = [];
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
+ const href = String(url);
+ fetched.push(href);
+ if (href === "https://mcp.linear.app/.well-known/oauth-protected-resource/mcp") {
+ return mcpHttpResponse({
+ resource: "https://mcp.linear.app/mcp",
+ authorization_servers: ["https://mcp.linear.app"],
+ scopes_supported: ["read", "write"],
+ });
+ }
+ if (href === "https://mcp.linear.app/.well-known/oauth-authorization-server") {
+ return mcpHttpResponse({
+ issuer: "https://mcp.linear.app",
+ authorization_endpoint: "https://mcp.linear.app/authorize",
+ token_endpoint: "https://mcp.linear.app/token",
+ registration_endpoint: "https://mcp.linear.app/register",
+ code_challenge_methods_supported: ["S256"],
+ token_endpoint_auth_methods_supported: ["none"],
+ });
+ }
+ if (href === "https://mcp.linear.app/register") {
+ return mcpHttpResponse({
+ client_id: "linear-registered-client",
+ redirect_uris: ["https://paperclip.example/api/tools/oauth/callback"],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ });
+ }
+ throw new Error(`unexpected fetch ${href}`);
+ });
+
+ const connectRes = await request(app)
+ .post(`/api/companies/${company.id}/tools/apps/connect`)
+ .send({ galleryKey: "linear", name: "Linear", grantKind: "user" })
+ .expect(201);
+
+ const startUrl = new URL(connectRes.body.auth.startUrl);
+ expect(startUrl.origin + startUrl.pathname).toBe("https://mcp.linear.app/authorize");
+ expect(startUrl.searchParams.get("client_id")).toBe("linear-registered-client");
+ expect(fetched).toContain("https://mcp.linear.app/register");
+ expect(fetched.some((href) => href.startsWith("https://linear.app/"))).toBe(false);
+ });
+
it("returns a pre-scoped personal Notion callback directly to Permissions", async () => {
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
@@ -10116,9 +10174,31 @@ describeEmbeddedPostgres("tool access service", () => {
);
expect(state).toBeTruthy();
+ // The provider's redirect is a cross-site navigation: Paperclip commits a
+ // page at once (Railway's consent page otherwise replaces itself after ~2s)
+ // and leaves the state unconsumed for the same-origin repeat.
+ const interstitialRes = await request(app)
+ .get("/api/tools/oauth/callback")
+ .set("Accept", "text/html")
+ .set("Sec-Fetch-Site", "cross-site")
+ .set("Sec-Fetch-Mode", "navigate")
+ .query({ state, code: "notion-choice-code" });
+ expect(interstitialRes.status).toBe(200);
+ expect(interstitialRes.headers["cache-control"]).toBe("no-store");
+ expect(interstitialRes.text).toContain(
+ ``,
+ );
+ const [pendingConnection] = await db
+ .select()
+ .from(toolConnections)
+ .where(eq(toolConnections.id, connectRes.body.connectionId));
+ expect(pendingConnection?.status).not.toBe("active");
+
const callbackRes = await request(app)
.get("/api/tools/oauth/callback")
.set("Accept", "text/html")
+ .set("Sec-Fetch-Site", "same-origin")
+ .set("Sec-Fetch-Mode", "navigate")
.query({ state, code: "notion-choice-code" });
expect(callbackRes.status).toBe(303);
diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts
index 0f4ad7d8b0..f4c5119497 100644
--- a/server/src/routes/tool-access.ts
+++ b/server/src/routes/tool-access.ts
@@ -191,6 +191,29 @@ export function connectionIntentOAuthOutcomeHtml(input: {
return `Connection authorization
Returning to Paperclip…
`;
}
+// Some providers' consent pages navigate to this callback and, if their own
+// page is still on screen ~2s later, replace it with a "you can close this
+// window" screen (Railway does exactly this). Exchanging the code and
+// discovering the tool catalog routinely takes longer than that, so the
+// provider's timer wins and the browser never lands back in Paperclip even
+// though the connection completed. For a cross-site browser navigation, commit
+// a Paperclip document immediately and repeat the same request from it; the
+// repeat is same-origin and does the slow work.
+export function isCrossSiteOAuthCallbackNavigation(req: Request): boolean {
+ return req.get("sec-fetch-site") === "cross-site"
+ && req.get("sec-fetch-mode") === "navigate";
+}
+
+export function oauthCallbackInterstitialHtml(continuePath: string): string {
+ const attribute = continuePath
+ .replaceAll("&", "&")
+ .replaceAll("\"", """)
+ .replaceAll("<", "<");
+ // A meta refresh alone, not a script as well: the OAuth code is single-use, so
+ // two racing follow-ups would let the loser render an expired-state error.
+ return `Finishing connection
Finishing your connection…
`;
+}
+
function normalizeCloudConnectorEnrollmentReturnTo(returnTo?: string | null): string | null {
if (!returnTo || returnTo.length > 2_048) return null;
try {
@@ -1331,6 +1354,13 @@ function connectorEnrollmentPrincipal(req: Request): string {
await assertToolConnectionConfigureAccess(req, pendingConnection);
}
const acceptsHtml = req.get("accept")?.includes("text/html") === true;
+ if (acceptsHtml && isCrossSiteOAuthCallbackNavigation(req)) {
+ // State is only peeked above, so the same-origin repeat still owns it.
+ res.set("Cache-Control", "no-store");
+ res.set("Referrer-Policy", "no-referrer");
+ res.type("html").send(oauthCallbackInterstitialHtml(req.originalUrl));
+ return;
+ }
let result: Awaited>;
try {
result = await svc.completeOAuthCallback({
diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts
index 880f66e956..982a0e82bc 100644
--- a/server/src/services/tool-access.ts
+++ b/server/src/services/tool-access.ts
@@ -9018,9 +9018,22 @@ export function toolAccessService(
const galleryMethod = galleryEntry
? connectionMethodForConnection(galleryEntry, connection)
: null;
+ // Pinned endpoints describe the provider's console-registered OAuth app.
+ // A method that also offers dynamic registration registers against the MCP
+ // server's own authorization server (Linear: mcp.linear.app, not
+ // linear.app), which only discovery finds. So the pins stand in for
+ // discovery only when no registration is possible or the connection
+ // already carries an operator-entered client.
+ const storedOAuth = oauthConfig(connection);
+ const usesOperatorClient =
+ storedOAuth.clientRegistrationSource === "manual" &&
+ connection.ownership !== "dcr" &&
+ typeof storedOAuth.clientId === "string" &&
+ storedOAuth.clientId.trim().length > 0;
const hasCompleteGalleryEndpointHints = Boolean(
galleryMethod?.defaults?.authorizationEndpoint &&
- galleryMethod.defaults.tokenEndpoint,
+ galleryMethod.defaults.tokenEndpoint &&
+ (!galleryMethod.ownershipModes.includes("dcr") || usesOperatorClient),
);
// The smoke-lab fixture's endpoints are first-party and complete, so
// discovery is not just unnecessary there, it must not run: an unreachable
@@ -9744,6 +9757,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,
@@ -9751,10 +9771,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/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts
index e8ceef241b..f4d2307c42 100644
--- a/tests/e2e/app-not-connected.spec.ts
+++ b/tests/e2e/app-not-connected.spec.ts
@@ -119,7 +119,6 @@ test.describe.serial("not-connected app page", () => {
await page.getByRole("button", { name: "Reconnect", exact: true }).click();
await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20_000 });
await expect(page.getByText("Connect your own MCP server")).toBeVisible({ timeout: 20_000 });
- await page.getByRole("button", { name: "Save and continue" }).click();
await expect(page.getByText(mock.url)).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts
index 7b18344b2d..ddba28ec58 100644
--- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts
+++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts
@@ -161,22 +161,17 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
await linkInput.fill(mock.url);
await page.getByRole("button", { name: "Continue" }).click();
- // Access is chosen before credentials so the user knows who and which
- // agents will receive the connection before Paperclip contacts it.
- await expect(page.getByText("Which humans can use this credential?")).toBeVisible();
- await page.getByRole("button", { name: "Save and continue" }).click();
-
- // LinkKey step keeps the BYO connection heading. Mock doesn't
- // require a key — leave the default "No" answer.
+ // There is no separate Access step: the link opens the key screen, which
+ // states the default access in one line. The mock needs no key, and a
+ // credential challenge from the server is what would ask for one.
await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-02-key-step.png`, fullPage: true });
// Submit (button label is "Check link").
await page.getByRole("button", { name: /Check link/i }).click();
- // The Access choice was captured before credentials. A successful generic
- // probe now commits discovered actions and risk defaults transactionally,
- // so the key check lands directly on success.
+ // A successful generic probe commits discovered actions and the stated
+ // access defaults transactionally, so the key check lands on success.
await expect(page.getByRole("heading", { name: /is ready\.$/i })).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-05-success.png`, fullPage: true });
diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts
index 57ce962fda..7d37d75b4b 100644
--- a/tests/e2e/chat-adapters-ui-providers.spec.ts
+++ b/tests/e2e/chat-adapters-ui-providers.spec.ts
@@ -140,10 +140,14 @@ test.describe.serial("native chat adapter UI", () => {
'[role="listitem"][data-app-slug="github"]',
);
await expect(connector).toBeVisible();
- await connector.getByRole("button", { name: "Connect GitHub" }).click();
+ // Without the cloud connector GitHub's default method is a token, so the
+ // card's verb is "Add key" rather than "Connect".
+ await connector.getByRole("button", { name: "Add key GitHub" }).click();
await expect(page).toHaveURL(/\/apps\/connect\?/);
expect(new URL(page.url()).searchParams.get("source")).toBe("github");
+ // Identity is a stated default; its choices sit behind "Change".
+ await page.getByRole("button", { name: "Change", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Connect GitHub as" }),
).toBeVisible();
@@ -218,7 +222,8 @@ test.describe.serial("native chat adapter UI", () => {
await expect(connector).toBeVisible({ timeout: 30_000 });
if (provider.provider === "github") {
const tools = page.locator('[role="listitem"][data-app-slug="github"]');
- await tools.getByRole("button", { name: "Connect GitHub", exact: true }).click();
+ await tools.getByRole("button", { name: "Add key GitHub", exact: true }).click();
+ await page.getByRole("button", { name: "Change", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connect GitHub as" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Choose how to connect" })).toHaveCount(0);
await page.goto(`/${seed.prefix}/apps`);
diff --git a/tests/e2e/connection-intents.spec.ts b/tests/e2e/connection-intents.spec.ts
index 1cb1e033fa..c3dce4e7ff 100644
--- a/tests/e2e/connection-intents.spec.ts
+++ b/tests/e2e/connection-intents.spec.ts
@@ -234,7 +234,6 @@ test("store setup and task connection intent share one fake provider through con
.getByPlaceholder("https://example.com/actions")
.fill(provider.url);
await page.getByRole("button", { name: "Continue" }).click();
- await page.getByRole("button", { name: "Save and continue" }).click();
await page.getByRole("button", { name: /Check link/i }).click();
// A no-auth read-only provider can complete the access/install defaults in
// one commit. Other methods exercise the same intermediate steps in the
diff --git a/tests/e2e/connection-reviews.spec.ts b/tests/e2e/connection-reviews.spec.ts
index 4d9fb97230..385f1a4a1d 100644
--- a/tests/e2e/connection-reviews.spec.ts
+++ b/tests/e2e/connection-reviews.spec.ts
@@ -149,7 +149,6 @@ for (const journey of [
.getByPlaceholder("https://example.com/actions")
.fill(provider.url);
await page.getByRole("button", { name: "Continue", exact: true }).click();
- await page.getByRole("button", { name: "Save and continue" }).click();
await page.getByRole("button", { name: /Check link/i }).click();
await expect(
page.getByRole("heading", { name: /is ready/i }),
diff --git a/tests/e2e/execution-recovery/recovery.spec.ts b/tests/e2e/execution-recovery/recovery.spec.ts
index 9af3b145d3..0ef482333e 100644
--- a/tests/e2e/execution-recovery/recovery.spec.ts
+++ b/tests/e2e/execution-recovery/recovery.spec.ts
@@ -324,11 +324,11 @@ for (const journey of [
await page
.getByRole("button", { name: "Continue", exact: true })
.click();
+ await page.getByRole("button", { name: "Change", exact: true }).click();
await page.getByRole("radio", { name: "Just agents I pick" }).click();
await page.getByRole("button", { name: /Select agents/ }).click();
await page.getByRole("checkbox", { name: /Archive holder/ }).check();
await page.keyboard.press("Escape");
- await page.getByRole("button", { name: "Save and continue" }).click();
await page.getByRole("button", { name: /Check link/i }).click();
await expect(
page.getByRole("heading", { name: /is ready/i }),
diff --git a/tests/e2e/in-feed-native/connections.spec.ts b/tests/e2e/in-feed-native/connections.spec.ts
index 7edc9249f2..c242a14ea6 100644
--- a/tests/e2e/in-feed-native/connections.spec.ts
+++ b/tests/e2e/in-feed-native/connections.spec.ts
@@ -71,11 +71,11 @@ for (const journey of ['connect', 'decline', 'restart'] as const) test(`fresh na
await custom.getByRole('button', { name: 'Connect your own MCP server' }).click();
await page.getByPlaceholder('https://example.com/actions').fill(`http://127.0.0.1:${port}/`);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
+ await page.getByRole('button', { name: 'Change', exact: true }).click();
await page.getByRole('radio', { name: 'Just agents I pick' }).click();
await page.getByRole('button', { name: /Select agents/ }).click();
await page.getByRole('checkbox', { name: /Archive holder/ }).check();
await page.keyboard.press('Escape');
- await page.getByRole('button', { name: 'Save and continue' }).click();
await page.getByRole('button', { name: /Check link/i }).click();
await expect(page.getByRole('heading', { name: /is ready/i })).toBeVisible({ timeout: 30_000 });
const [connection] = (await api(`/companies/${company.id}/tools/connections`)).connections;
diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx
index 2864c7f8cd..2562372c36 100644
--- a/ui/src/features/connections/ConnectionSetupFlow.tsx
+++ b/ui/src/features/connections/ConnectionSetupFlow.tsx
@@ -74,6 +74,7 @@ import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
+import { askFirstCatalogEntryIdsFor } from "./connection-defaults";
import { AppLogo } from "@/pages/apps/AppLogo";
import { appApplicationSourceSlug } from "@/pages/apps/app-definition-display";
import { UnverifiedServerBadge } from "@/pages/apps/UnverifiedServerBadge";
@@ -102,7 +103,7 @@ import {
} from "@/pages/apps/generic-mcp-connect";
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
-type Step = "gallery" | "access" | "key" | "success";
+type Step = "gallery" | "key" | "success";
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
type EnrollmentAccessState = {
@@ -184,7 +185,6 @@ function oauthCallbackErrorMessage(outcome: string | null, code: string | null):
}
const ROUTE_STAGE_BY_STEP: Partial> = {
- access: "access",
key: "setup",
success: "complete",
};
@@ -196,10 +196,8 @@ export function requestedConnectionInitialStep(input: {
hasPrefilledLink: boolean;
zapierSource: boolean;
}): Step {
- if (input.requestedAppKey) {
- return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access";
- }
- return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery";
+ if (input.requestedAppKey) return "key";
+ return input.hasPrefilledLink || input.zapierSource ? "key" : "gallery";
}
export function requestedConnectionEntry(input: {
@@ -305,24 +303,23 @@ function withConnectionIntent(href: string, interactionId?: string | null): stri
type AppAccessSelection = "all_agents" | { agentIds: string[] };
-// Access comes before credentials so the reader knows what identity and reach
-// the secret is about to get before they share it (PAP-17835).
-const STEP_LABELS = ["Pick app", "Access", "Add your key"];
+// PAP-659: identity and agent reach are no longer a step. They are resolved to
+// a default, stated in one line above the primary action, and changed either in
+// the Advanced disclosure on this screen or on the Permissions tab afterwards.
+const STEP_LABELS = ["Pick app", "Add your key"];
const STEP_INDEX: Record, number> = {
gallery: 0,
- access: 1,
- key: 2,
-};
-const SELECTED_APP_STEP_INDEX: Record, number> = {
- access: 0,
key: 1,
};
-const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"];
+const SELECTED_APP_STEP_INDEX: Record, number> = {
+ key: 0,
+};
+const ZAPIER_STEP_LABELS = ["Add MCP URL"];
// Waiting for browser sign-in happens *inside* the flow's last step, so the
// waiting screen reuses the step model of the flow that opened it. It must not
-// append a trailing step the flow never lands on: a stepper that grows from two
-// dots to three the moment you press Connect reads as a step you missed.
-const OAUTH_SIGN_IN_STEP_LABELS = ["Access", "Sign in"];
+// append a trailing step the flow never lands on: a stepper that grows from one
+// dot to two the moment you press Connect reads as a step you missed.
+const OAUTH_SIGN_IN_STEP_LABELS = ["Sign in"];
/**
* Which identity a fresh connection should default to (PAP-17835).
@@ -394,16 +391,13 @@ function availableToolConnectionMethod(
function recommendedSetupConnectionMethod(
methods: readonly ConnectionMethodDef[],
): ConnectionMethodDef | null {
- const recommended = getRecommendedConnectionMethod(methods);
- // Capability choices (for example Google Workspace read versus write) have
- // an intentional default. Unrelated region/authentication variants should
- // still ask the operator to choose unless only one is available. A method
- // that supports an agent-owned identity must also be selected before the
- // Access step: that ownership decision cannot be represented by a legacy
- // compatibility method such as GitHub's advanced PAT option.
- return methods.length === 1 || recommended?.capabilityProfile || recommended?.grantKinds?.includes("agent")
- ? recommended
- : null;
+ // PAP-659 C1: every connector arrives with a method already chosen.
+ // `getRecommendedConnectionMethod` already ranks managed/one-click OAuth over
+ // customer-owned OAuth over API keys, and write/draft capability over read;
+ // this used to throw that ranking away for multi-method apps and ask the
+ // operator instead. The alternates are still reachable, in the Advanced
+ // disclosure, so nothing became unavailable — it just stopped blocking.
+ return getRecommendedConnectionMethod(methods);
}
function recommendedManagedConnectorMethod(
@@ -711,6 +705,9 @@ function StandardConnectionSetupFlow({
const [authorizationHost, setAuthorizationHost] = useState(null);
const directOAuthAccessConfirmedRef = useRef(false);
const directOAuthRetryingRef = useRef(false);
+ // A draft that sign-in already created, to be resumed with an operator's own
+ // OAuth client when automatic registration was refused.
+ const customerClientResumeRef = useRef(null);
const hydratedResumeConnectionIdRef = useRef(null);
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState(null);
const oauthPopupRef = useRef(null);
@@ -902,7 +899,7 @@ function StandardConnectionSetupFlow({
resetGenericAuthState();
setCredentials({});
setConnectResult(null);
- setStep("access");
+ setStep("key");
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
return;
}
@@ -931,7 +928,7 @@ function StandardConnectionSetupFlow({
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId)));
- setStep("access");
+ setStep("key");
navigate(
credentialSource === "vercel_connect"
? withConnectionIntent(vercelConnectSourceHref(picked.slug), connectionIntentId)
@@ -1298,10 +1295,13 @@ function StandardConnectionSetupFlow({
? configValues
: undefined,
applicationId: prefill.applicationId,
- ...(resumeConnectionId ? { resumeConnectionId } : reconnectConnectionId ? { reconnectConnectionId } : {}),
+ ...((resumeConnectionId ?? customerClientResumeRef.current)
+ ? { resumeConnectionId: resumeConnectionId ?? customerClientResumeRef.current! }
+ : reconnectConnectionId ? { reconnectConnectionId } : {}),
...(requestedGrantKind !== "organization" ? { grantKind: requestedGrantKind } : {}),
...(requestedGrantKind === "agent" ? { subjectAgentId: [...installAgentIds][0] } : {}),
});
+ customerClientResumeRef.current = null;
} else {
const genericPayload = genericConnectPayload({
link: linkUrl,
@@ -1412,7 +1412,12 @@ function StandardConnectionSetupFlow({
const guidance = genericConnectGuidance(code, error instanceof Error ? error.message : null);
setLinkGuidance(guidance);
setGenericOAuthPending(false);
- if (guidance.focus === "credentials") setLinkAdvancedOpen(true);
+ if (guidance.focus === "credentials") {
+ // The probe is the answer to "does it need a key?", so the field
+ // appears now rather than being offered as a guess beforehand.
+ setLinkNeedsKey(true);
+ setLinkAdvancedOpen(true);
+ }
return;
}
pushToast({
@@ -1599,10 +1604,6 @@ function StandardConnectionSetupFlow({
zapierSource,
]);
- useEffect(() => {
- if (reconnectConnection?.connectionPurpose === "ai" && step === "access") setStep("key");
- }, [reconnectConnection?.connectionPurpose, step]);
-
// Resume the exact method and non-secret provider configuration that the
// interrupted draft already chose. Secrets are intentionally never read back
// into the browser; credential-based methods ask for a replacement value.
@@ -1693,16 +1694,10 @@ function StandardConnectionSetupFlow({
const enabledIds = Object.entries(enabledMap)
.filter(([, on]) => on)
.map(([id]) => id);
- const askFirstRiskLevels = new Set(
- Array.isArray(connected.suggestedDefaults.askFirstRiskLevels)
- ? connected.suggestedDefaults.askFirstRiskLevels.filter(
- (riskLevel): riskLevel is string => typeof riskLevel === "string",
- )
- : [],
+ const askFirstIds = askFirstCatalogEntryIdsFor(
+ connected,
+ (catalogEntryId) => Boolean(enabledMap[catalogEntryId]),
);
- const askFirstIds = connected.actions.canMakeChanges
- .filter((action) => enabledMap[action.catalogEntryId] && askFirstRiskLevels.has(action.riskLevel))
- .map((action) => action.catalogEntryId);
// The Access step asks one question about agent reach, so profile access
// and installs are committed to the same target set instead of drifting
// apart behind two separate wizard screens.
@@ -1726,9 +1721,9 @@ function StandardConnectionSetupFlow({
},
onError: (error) => {
// Creation must feel transactional: a failed commit returns the operator
- // to Access with their identity and agent selections intact rather than
- // stranding them on a half-made connection.
- setAppStep("access");
+ // to the connect screen with their identity and agent selections intact
+ // rather than stranding them on a half-made connection.
+ setAppStep("key");
pushToast({
title: "Couldn’t finish setup",
body: error instanceof Error ? error.message : "Please try again.",
@@ -1911,6 +1906,99 @@ function StandardConnectionSetupFlow({
);
}
+ const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
+ const setupCredentialSourceMethods = preEnrollmentManagedMethod
+ ? [preEnrollmentManagedMethod]
+ : credentialSourceMethods;
+
+ // The stated default's identity line only makes sense when there *is* a
+ // credential, so it reads the selected method's auth kind.
+ const accessStepMethod = entry
+ ? (connectionMethodKey
+ ? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
+ : setupCredentialSourceMethods[0] ?? null)
+ : null;
+ const accessStepAuthKind: ToolConnectionAuthKind = entry
+ ? accessStepMethod?.auth ?? "none"
+ : linkAuthMode === "none"
+ ? "none"
+ : linkAuthMode === "oauth"
+ ? "oauth"
+ : "api_key";
+ // PAP-659 C0: the resolved default is stated, not asked. `Change` opens the
+ // same controls the deleted Access step owned, inline and never blocking.
+ const renderConnectionDefaults = step === "key" ? (
+ (extra?: ReactNode, forceOpen?: boolean) => (
+ Boolean(reason))}
+ authKind={accessStepAuthKind}
+ grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
+ grantKind={effectiveGrantKind}
+ setGrantKind={setGrantKind}
+ installChoice={installChoice}
+ setInstallChoice={setInstallChoice}
+ installAgentIds={installAgentIds}
+ setInstallAgentIds={setInstallAgentIds}
+ lockedAgentId={requestedAgentId}
+ capabilities={galleryQuery.data?.capabilities}
+ githubIdentity={entry?.slug === "github"}
+ identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
+ preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
+ disabled={connectMutation.isPending || oauthStartMutation.isPending}
+ />
+ )
+ ) : null;
+ // A provider can advertise registration and still refuse this deployment's
+ // callback (Asana refuses hosted ones). When the method also accepts an
+ // operator's own OAuth client, that client is the recovery path, so it sits
+ // in the same Advanced panel and opens itself once sign-in has failed.
+ const automaticCustomerClientMethod = automaticOAuthEntry
+ && entryAutomaticOAuthMethod
+ && connectionMethodAcceptsCustomerOAuthClient(entryAutomaticOAuthMethod)
+ ? entryAutomaticOAuthMethod
+ : null;
+ const automaticCustomerClientFields = automaticCustomerClientMethod && automaticOAuthEntry ? (
+
+ ) : null;
+ const connectionDefaults = renderConnectionDefaults?.() ?? null;
+ const curatedOAuthDefaults = automaticCustomerClientFields
+ ? renderConnectionDefaults?.(
+ automaticCustomerClientFields,
+ oauthPhase === "error" || curatedOAuthClientId.trim().length > 0,
+ ) ?? null
+ : connectionDefaults;
+
const showCuratedOAuthState = Boolean(
automaticOAuthEntry
&& step === "key"
@@ -1950,15 +2038,42 @@ function StandardConnectionSetupFlow({
} : undefined}
authorizationHost={authorizationHost}
authorizationUrl={authorizationFallbackUrl}
+ guidance={entry?.slug === "railway" && accessStepMethod ? (
+
+
{accessStepMethod.guidanceMd}
+
+ {accessStepMethod.warnings?.map((warning) =>
{warning}
)}
+
+
+ ) : null}
+ defaults={curatedOAuthDefaults}
onOpenAuthorization={openAuthorizationTab}
onRetry={async () => {
+ const firstAttempt = !directOAuthAccessConfirmedRef.current;
+ directOAuthAccessConfirmedRef.current = true;
setOAuthError(null);
setOAuthPhase("starting");
const connection = connectResult?.connection ?? resumableOAuthConnection;
+ if (connection && automaticCustomerClientFields && curatedOAuthClientId.trim()) {
+ // The draft exists but its client must change: resume it through
+ // connect so the operator's client replaces the refused registration.
+ customerClientResumeRef.current = connection.id;
+ connectApp(automaticOAuthEntry);
+ return;
+ }
if (connection) {
startOAuth(connection);
return;
}
+ if (
+ firstAttempt
+ && !resumeConnectionId
+ && !applicationsQuery.isError
+ && !connectionsQuery.isError
+ ) {
+ connectApp(automaticOAuthEntry);
+ return;
+ }
// The create request may have reached the server even when its
// response did not reach the browser. Re-read both resources before
@@ -1988,20 +2103,16 @@ function StandardConnectionSetupFlow({
? { applicationId: prefill.applicationId, draftOnly: true }
: {},
);
- if (!directOAuthAccessConfirmedRef.current && !resumeConnectionId) {
- if (refreshedConnection) {
- setGrantKind(
- refreshedConnection.credentialPolicy === "per_user"
- ? "user"
- : refreshedConnection.credentialPolicy === "per_agent"
- ? "agent"
- : "organization",
- );
- }
- setOAuthPhase("entry");
- setOAuthError(null);
- setStep("access");
- return;
+ if (refreshedConnection && !resumeConnectionId) {
+ // Adopt the durable draft's identity before resuming it, so the
+ // stated default on screen matches what is about to be authorized.
+ setGrantKind(
+ refreshedConnection.credentialPolicy === "per_user"
+ ? "user"
+ : refreshedConnection.credentialPolicy === "per_agent"
+ ? "agent"
+ : "organization",
+ );
}
if (refreshedConnection) {
startOAuth(refreshedConnection);
@@ -2016,7 +2127,7 @@ function StandardConnectionSetupFlow({
oauthHandoffAbortRef.current?.abort();
setOAuthPhase("entry");
setOAuthError(null);
- setAppStep("access");
+ backToGallery();
}}
onCancel={() => {
oauthHandoffAbortRef.current?.abort();
@@ -2042,6 +2153,7 @@ function StandardConnectionSetupFlow({
error={oauthError}
authorizationHost={authorizationHost}
authorizationUrl={authorizationFallbackUrl}
+ defaults={connectionDefaults}
onOpenAuthorization={openAuthorizationTab}
onRetry={() => {
setOAuthError(null);
@@ -2074,10 +2186,6 @@ function StandardConnectionSetupFlow({
connectResult?.application.name ??
entry?.name ??
(linkName.trim() || defaultGenericMcpName(linkUrl) || "this app");
- const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
- const setupCredentialSourceMethods = preEnrollmentManagedMethod
- ? [preEnrollmentManagedMethod]
- : credentialSourceMethods;
const credentialSourceApps = vercelConnectMode
? visibleGalleryApps.filter(
(app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0,
@@ -2091,7 +2199,7 @@ function StandardConnectionSetupFlow({
: undefined;
const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
- const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => setAppStep("access") }) ?? (aiMethod && selectedCompanyId ? <> backToGallery() }) ?? (aiMethod && selectedCompanyId ? <> onCancel ? onCancel() : navigate("/apps")}
onComplete={result => { onComplete?.({ connectionId: result.connectionId }); if (!onComplete) navigate(`/apps/${result.connectionId}/permissions`); }}
/>> : undefined) : undefined;
+ // One screen per connector (PAP-659): a selected app has a single step, so
+ // its stepper collapses to nothing rather than showing one lonely dot.
const stepLabels = reconnectConnection?.connectionPurpose === "ai" ? ["Reconnect account"] : credentialStep !== undefined
- ? ["Access", "Connect account"]
+ ? ["Connect account"]
: zapierSource
? ZAPIER_STEP_LABELS
: entry && setupCredentialSourceMethods.length > 1
- ? ["Access", "Choose connection"]
+ ? ["Choose connection"]
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
- ? ["Access", "Sign in"]
+ ? ["Sign in"]
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
- ? ["Access", "Share sheet"]
+ ? ["Share sheet"]
: entry
- ? ["Access", "Add your key"]
+ ? ["Add your key"]
: STEP_LABELS;
- // The Access step's identity question only makes sense when there *is* a
- // credential, so it reads the selected method's auth kind.
- const accessStepMethod = entry
- ? (connectionMethodKey
- ? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
- : setupCredentialSourceMethods[0] ?? null)
- : null;
- const accessStepAuthKind: ToolConnectionAuthKind = entry
- ? accessStepMethod?.auth ?? "none"
- : linkAuthMode === "none"
- ? "none"
- : linkAuthMode === "oauth"
- ? "oauth"
- : "api_key";
- // Name the actual next effect: multi-method apps and enrollment still have
- // a local setup screen, even when OAuth is already the selected method.
- const accessContinuesToProvider = Boolean(directOAuthEntry);
- const accessSubmitLabel = accessContinuesToProvider
- ? `Continue to ${entry?.name ?? "sign-in"}`
- : accessStepAuthKind === "oauth" ? "Continue" : "Save and continue";
-
const stepIndex = reconnectConnection?.connectionPurpose === "ai" ? 0 : (zapierSource || entry) && step !== "gallery" && step !== "success"
? SELECTED_APP_STEP_INDEX[step]
: step === "success"
@@ -2150,7 +2239,9 @@ function StandardConnectionSetupFlow({
? vercelConnectMode
? "Choose a reviewed app to connect through Vercel."
: "Pick the app you want your agents to use."
- : `Step ${stepIndex + 1} of ${stepLabels.length}`
+ : stepLabels.length <= 1
+ ? "Connect now — permissions and access are yours to change afterwards."
+ : `Step ${stepIndex + 1} of ${stepLabels.length}`
}
step={step}
activeIndex={stepIndex}
@@ -2193,7 +2284,7 @@ function StandardConnectionSetupFlow({
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
setGrantKind(reconnectGrantKind ?? (requestedAgentId ? "user" : "organization"));
- setStep("access");
+ setStep("key");
}}
/>
)}
@@ -2205,7 +2296,7 @@ function StandardConnectionSetupFlow({
This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.
- {needsKey
- ? "Paste the key this app gave you."
- : "Most servers just work from the address — pick Yes only if the server gave you a key, or if it asks you to sign in."}
-
Back
@@ -3353,6 +3414,7 @@ function KeyStep({
googleSheetsError,
onGoogleSheetsLinksChange,
submitting,
+ renderDefaults,
onBack,
onConnect,
}: {
@@ -3380,6 +3442,11 @@ function KeyStep({
googleSheetsError: string | null;
onGoogleSheetsLinksChange: (next: string) => void;
submitting: boolean;
+ /**
+ * Builds the one Advanced disclosure this screen has (PAP-659 C0). The key
+ * step contributes its own settings to it rather than opening a second one.
+ */
+ renderDefaults?: (extra?: ReactNode, forceOpen?: boolean) => ReactNode;
onBack: () => void;
onConnect: () => void;
}) {
@@ -3457,7 +3524,6 @@ function KeyStep({
const configFields = allConfigFields.filter((field) => !field.hidden);
const standardConfigFields = configFields.filter((field) => field.advanced !== true);
const advancedConfigFields = configFields.filter((field) => field.advanced === true);
- const [advancedOpen, setAdvancedOpen] = useState(false);
const configFilled = allConfigFields.every((field) => {
if (!field.required) return true;
const value = configValues[field.key];
@@ -3476,8 +3542,7 @@ function KeyStep({
const optionalCustomerOAuthClient = !usingVercel
&& acceptsCustomerOAuthClient
&& !customerOAuthClientRequired;
- const hasReadOnlyAlternatives = capabilityGroups.length > 1 && capabilityGroups.some((group) => group.key === "read");
- const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient || hasReadOnlyAlternatives;
+ const hasAlternateMethods = capabilityGroups.length > 1 || capabilityMethods.length > 1;
const capabilitySelection = capabilityGroups.length > 1 ? (
@@ -3523,6 +3588,9 @@ function KeyStep({
{usingCustomGoogleOAuth ? "Use Paperclip instead" : "Use your own Google OAuth app"}
) : capabilityMethods.length > 1 ? (
+ // PAP-659 C1: the ranked default is already selected. This stays as the
+ // way to pick something else, one disclosure away, rather than a question
+ // the screen opens with.
Choose a connection method to continue.}
) : null;
+ const hasAdvancedSettings = advancedConfigFields.length > 0
+ || optionalCustomerOAuthClient
+ || hasAlternateMethods;
+ // Keep the disclosure open when what is inside it is load-bearing right now:
+ // a non-default method in use, or a selection the connector still needs.
+ const forceAdvancedOpen = usingCustomGoogleOAuth || !hasMethodSelection;
+ const advancedSettings = hasAdvancedSettings ? (
+
Back
@@ -3823,10 +3892,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.`}