diff --git a/doc/connections/ASANA.md b/doc/connections/ASANA.md new file mode 100644 index 00000000000..117b7d0394b --- /dev/null +++ b/doc/connections/ASANA.md @@ -0,0 +1,56 @@ +# Asana + +Paperclip uses [Asana's official remote MCP server](https://developers.asana.com/docs/integrating-with-asanas-mcp-server) +at `https://mcp.asana.com/v2/mcp`. The default connection uses Paperclip's +shared OAuth app through the Cloud connector broker. An enrolled self-hosted +instance can use that same app. **Use your own Asana OAuth app** is available +without enrolling the instance, including when the shared app is unavailable. + +## Your own app + +1. Open [Asana My apps](https://app.asana.com/0/my-apps) and create an + **Asana MCP** app. An API app or personal access token will not authorize MCP. +2. Under OAuth, copy the exact callback displayed in Paperclip into Redirect URLs. + For local development Paperclip uses `localhost`, even if opened at `127.0.0.1`. + Public deployments should configure their canonical HTTPS URL. +3. Under Manage distribution, select the workspaces that can use the app and save. +4. Enter the client ID and client secret in Paperclip and continue to Asana. + +MCP v2 still requires OAuth and a preregistered client; it does not offer dynamic +client registration. Asana MCP currently grants one fixed `default` scope. +Paperclip's action permissions govern which discovered tools an agent can use. +The client secret is encrypted in the local credential vault and sent only to +the token endpoint. Resuming setup with the same client ID preserves the secret; +changing the client ID requires the matching new secret. + +## Discovery and lifecycle + +Asana's live v2 resource challenge points to +`https://mcp.asana.com/.well-known/oauth-protected-resource/v2`. The root metadata +still describes the retired v1 issuer. The curated method pins the v2 discovery +URL and re-resolves it for saved connections, repairing drafts that cached v1. +Saved manual-client bindings from that exact v1 issuer/resource are repaired for +Asana v2 while preserving company and callback checks. Other binding changes +still require new credentials. Secret reuse is shown only when the requesting +user has an active grant with a saved client-secret reference. +Authorization and token exchange use `https://app.asana.com/-/oauth_authorize` +and `https://app.asana.com/-/oauth_token`, with PKCE S256. + +The managed `asana.mcp` profile uses the existing signed Cloud connector protocol, +including instance-bound encrypted token delivery and refresh. Availability is +advertised only after the broker has its MCP app credentials and profile enabled. +The shared app's provider secret is held by the broker, never distributed to +instances. Rolling out the shared option requires the companion broker support, +app registration, allowed distribution, and provider configuration. + +## Verify + +Test both managed and custom sign-in in the browser. Confirm that callback and +workspace selection succeed, tools load, and a read-only tool such as **Get me** +works through **Test** as an installed agent. Also verify canceled/interrupted +setup can resume, and that refresh works after token expiration. Never place +provider tokens or client secrets in screenshots or test fixtures. + +Also connect a second account after renaming the first connection. Both custom +and shared app connections must finish with separate permission profiles, even +when the first profile still uses the provider's original name. diff --git a/doc/connections/README.md b/doc/connections/README.md index ac7196cd2bf..7e1bfe8f051 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md). Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), -[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md), +[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 162bc2b09cc..2ef0bc115fc 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -56,6 +56,23 @@ "reviewedAt": "2026-09-30", "liveProof": "not-run" }, + { + "app": "asana", + "method": "managed", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "default" + ], + "capability": "write", + "supportedActions": "Create/update tasks and projects authorized by the account.", + "evidence": [ + "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", + "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" + ], + "reviewedAt": "2026-09-30", + "liveProof": "Real OAuth, 39 MCP actions, Get me, and forced-expiry refresh verified with a local broker fixture on 2026-09-30." + }, { "app": "asana", "method": "mcp-own-oauth", @@ -68,10 +85,10 @@ "supportedActions": "Create/update tasks and projects authorized by the account.", "evidence": [ "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", - "https://mcp.asana.com/.well-known/oauth-protected-resource" + "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" ], "reviewedAt": "2026-09-30", - "liveProof": "not-run" + "liveProof": "OAuth, 39 MCP actions, and Get me verified in the embedded browser on 2026-09-30." }, { "app": "beehiiv", diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index adce8e88f1f..da32aedf637 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -572,10 +572,15 @@ describe("AppDefinition catalog", () => { (field) => field.key === "readOnly", )?.defaultValue, ).toBe(false); - // 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("asana")).toMatchObject({ + key: "managed", ownershipModes: ["platform_shared"], connectorProfile: "asana.mcp", + defaults: { serverUrl: "https://mcp.asana.com/v2/mcp", scopesHint: ["default"] }, + }); + expect(APP_DEFINITIONS.find((app) => app.slug === "asana")?.methods[1]).toMatchObject({ + key: "mcp-own-oauth", ownershipModes: ["customer"], oauthClientSecretRequired: true, + defaults: { discoveryUrl: "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" }, + consoleLinks: { register: "https://app.asana.com/0/my-apps" }, + }); expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]); expect(method("zapier")).toMatchObject({ key: "generated-url", diff --git a/packages/shared/src/app-definitions/asana.json b/packages/shared/src/app-definitions/asana.json index ecda6f4b603..4cc2859043a 100644 --- a/packages/shared/src/app-definitions/asana.json +++ b/packages/shared/src/app-definitions/asana.json @@ -17,27 +17,57 @@ "redirectConstraints": "https-or-loopback-http", "methods": [ { - "key": "mcp-own-oauth", + "key": "managed", "transport": "mcp_remote", "auth": "oauth", "ownershipModes": [ - "dcr", - "customer" + "platform_shared" ], - "whenToUse": "Sign in with Asana. Paperclip registers its own OAuth client automatically.", + "whenToUse": "Connect your Asana account with Paperclip's app.", "defaults": { "serverUrl": "https://mcp.asana.com/v2/mcp", "scopesHint": [ "default" ] }, - "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.", + "guidanceMd": "Sign in to Asana with Paperclip. Asana gives this connection access to the workspaces available to your account.", "riskTier": "S3", "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": [], + "oauthStrategy": "paperclip_cloud_connector", + "connectorProfile": "asana.mcp", + "grantKinds": [ + "user", + "agent" + ] + }, + { + "key": "mcp-own-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Register an OAuth app with Asana, then enter its client ID and secret.", + "defaults": { + "serverUrl": "https://mcp.asana.com/v2/mcp", + "discoveryUrl": "https://mcp.asana.com/.well-known/oauth-protected-resource/v2", + "scopesHint": [ + "default" + ] + }, + "guidanceMd": "Create an MCP app in Asana, then add the callback URL below under OAuth. Under Manage distribution, select your workspace and save. API apps do not work with Asana MCP.", + "riskTier": "S3", + "label": "Use your own OAuth app", + "consoleLinks": { + "register": "https://app.asana.com/0/my-apps", + "docs": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server" + }, + "warnings": [], + "oauthClientSecretRequired": true } ] } diff --git a/packages/shared/src/asana-connectors.ts b/packages/shared/src/asana-connectors.ts new file mode 100644 index 00000000000..6a585a7cdd6 --- /dev/null +++ b/packages/shared/src/asana-connectors.ts @@ -0,0 +1,10 @@ +export const ASANA_CONNECTOR_PROFILE_ID = "asana.mcp" as const; +export type AsanaConnectorProfileId = typeof ASANA_CONNECTOR_PROFILE_ID; + +export const ASANA_MCP_URL = "https://mcp.asana.com/v2/mcp"; +export const ASANA_MCP_DISCOVERY_URL = "https://mcp.asana.com/.well-known/oauth-protected-resource/v2"; +export const ASANA_CONNECTOR_SCOPES = ["default"] as const; + +export function isAsanaConnectorProfileId(value: string): value is AsanaConnectorProfileId { + return value === ASANA_CONNECTOR_PROFILE_ID; +} diff --git a/packages/shared/src/connection-setup-state.test.ts b/packages/shared/src/connection-setup-state.test.ts index e4cc83af584..252f1a1627b 100644 --- a/packages/shared/src/connection-setup-state.test.ts +++ b/packages/shared/src/connection-setup-state.test.ts @@ -17,22 +17,22 @@ import { */ describe("connectionSetupStateForApp", () => { it("puts one-click OAuth connectors in the authorize state", () => { - for (const slug of ["notion", "linear", "sentry", "stripe", "jira", "asana"]) { + for (const slug of ["notion", "linear", "sentry", "stripe", "jira"]) { 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 + it.each(["gmail", "asana"])("follows %s ownership availability rather than the catalog order", (slug) => { + // These providers 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"); + const app = getConnectableAppDefinition(slug)!; + expect(connectionSetupStateForApp(app)).toBe("register"); expect( connectionSetupStateForApp({ - ...gmail, - ownershipAvailability: { ...gmail.ownershipAvailability, platform_shared: true }, + ...app, + ownershipAvailability: { ...app.ownershipAvailability, platform_shared: true }, }), ).toBe("authorize"); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 92466095b9e..4cd4afc96e7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -343,6 +343,7 @@ export { export { APP_DEFINITIONS } from "./app-definitions.generated.js"; export * from "./google-workspace-connectors.js"; export * from "./github-connectors.js"; +export * from "./asana-connectors.js"; export { BLOCKED_MCP_PROVIDERS, SELF_SERVE_MCP_CANDIDATES, diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index b7b532a0536..de6cc3d98fe 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -2,7 +2,7 @@ import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionPurpos export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other"; export type OAuthRedirectConstraints = "https-or-loopback-http"; export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } -export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } +export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } export type SelfServeMcpAuthMode = diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index dfc05e91da2..5abb3a64f66 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -182,6 +182,8 @@ export interface ToolConnection { transportConfig: Record; config?: Record; credentialSecretRefs: ToolCredentialSecretRef[]; + /** Saved client secret for the requesting user, or the shared connection. Never includes secret material. */ + hasSavedOAuthClientSecret?: boolean; credentialRefs?: McpConnectionCredentialRef[]; healthStatus: ToolConnectionHealthStatus; /** Managed GitHub grant state; transient health failures do not require sign-in. */ diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index 9be04386dad..d55b839e1f8 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -6,6 +6,6 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{ try{return new URL(value).protocol==="https:";}catch{return false;} },{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"}); const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})}); -export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); +export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 7902a48008e..1ecfeb2e19e 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1095,6 +1095,31 @@ const apiKeyMethodFor = ( ); }; const specialMethodsFor = (entry) => { + if (entry.slug === "asana") return [ + oauthMethodFor(entry, "managed", entry.serverUrl, { + label: "Sign in with Asana", + ownershipModes: ["platform_shared"], + oauthStrategy: "paperclip_cloud_connector", + connectorProfile: "asana.mcp", + grantKinds: ["user", "agent"], + defaults: { serverUrl: entry.serverUrl, scopesHint: ["default"] }, + guidanceMd: "Sign in to Asana with Paperclip. Asana gives this connection access to the workspaces available to your account.", + whenToUse: "Connect your Asana account with Paperclip's app.", + warnings: [], + }), + { + ...customerOAuthMethodFor(entry), + defaults: { + serverUrl: entry.serverUrl, + discoveryUrl: "https://mcp.asana.com/.well-known/oauth-protected-resource/v2", + scopesHint: ["default"], + }, + oauthClientSecretRequired: true, + guidanceMd: "Create an MCP app in Asana, then add the callback URL below under OAuth. Under Manage distribution, select your workspace and save. API apps do not work with Asana MCP.", + consoleLinks: { register: "https://app.asana.com/0/my-apps", docs: entry.docsUrl }, + warnings: [], + }, + ]; if (entry.slug === "mem0" || entry.slug === "honcho") return [ apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, { guidanceMd: `Open the ${entry.name} dashboard, create an API key for the account agents should use, and paste it below.`, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 7fc5d900106..6aee6ee0ecf 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -10311,6 +10311,7 @@ describeEmbeddedPostgres("tool access service", () => { ])( "starts and completes OAuth with the same redirect URI at %s", async (origin) => { + const callbackOrigin = origin.replace("http://127.0.0.1:", "http://localhost:"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", @@ -10349,7 +10350,7 @@ describeEmbeddedPostgres("tool access service", () => { /^[A-Za-z0-9_-]{43}$/, ); expect(startUrl.searchParams.get("redirect_uri")).toBe( - `${origin}/api/tools/oauth/callback`, + `${callbackOrigin}/api/tools/oauth/callback`, ); const state = startUrl.searchParams.get("state"); expect(state).toBeTruthy(); @@ -10375,7 +10376,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(body.get("client_secret")).toBe("slack-client-secret"); expect(body.get("code_verifier")).toBeTruthy(); expect(body.get("redirect_uri")).toBe( - `${origin}/api/tools/oauth/callback`, + `${callbackOrigin}/api/tools/oauth/callback`, ); return { ok: true, @@ -10417,7 +10418,7 @@ describeEmbeddedPostgres("tool access service", () => { const callbackRes = await request(app) .get("/api/tools/oauth/callback") - .set("Host", new URL(origin).host) + .set("Host", new URL(callbackOrigin).host) .query({ state, code: "oauth-code" }); expect(callbackRes.status).toBe(200); @@ -10462,7 +10463,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(redirectState).toBeTruthy(); const redirectCallbackRes = await request(app) .get("/api/tools/oauth/callback") - .set("Host", new URL(origin).host) + .set("Host", new URL(callbackOrigin).host) .set("Accept", "text/html") .query({ state: redirectState, code: "oauth-code" }); @@ -10497,6 +10498,7 @@ describeEmbeddedPostgres("tool access service", () => { const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", "127.0.0.1:3200") + .set("Origin", "http://127.0.0.1:3200") .send({ galleryKey: "slack", name: "Loopback Slack workspace" }); expect(connectRes.status).toBe(201); @@ -11616,11 +11618,89 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana", + oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" }, + }); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + await db.update(toolConnections).set({ config: { + ...connection.config, + oauth: { ...connection.config.oauth as Record, + issuer: "https://mcp.asana.com", authorizationUrl: "https://mcp.asana.com/authorize", + tokenUrl: "https://mcp.asana.com/token", resource: "https://mcp.asana.com", + clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com", + clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback" }, + } }).where(eq(toolConnections.id, connected.connectionId)); + const calls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + calls.push(String(url)); + if (String(url) === "https://mcp.asana.com/.well-known/oauth-protected-resource/v2") { + return mcpHttpResponse({ resource: "https://mcp.asana.com/v2/mcp", + authorization_servers: ["https://app.asana.com"], scopes_supported: ["default"] }); + } + if (String(url) === "https://app.asana.com/.well-known/oauth-authorization-server") { + return mcpHttpResponse({ issuer: "https://app.asana.com", + authorization_endpoint: "https://app.asana.com/-/oauth_authorize", + token_endpoint: "https://app.asana.com/-/oauth_token", + token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], + code_challenge_methods_supported: ["S256"] }); + } + throw new Error(`unexpected fetch ${url}`); + }); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "http://localhost:3200/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + }); + const authorization = new URL(started.authorizationUrl); + expect(authorization.origin + authorization.pathname).toBe("https://app.asana.com/-/oauth_authorize"); + expect(authorization.searchParams.get("resource")).toBe("https://mcp.asana.com/v2/mcp"); + expect(authorization.searchParams.get("scope")).toBe("default"); + expect(authorization.searchParams.get("client_id")).toBe("asana-client"); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(calls).toEqual([ + "https://mcp.asana.com/.well-known/oauth-protected-resource/v2", + "https://app.asana.com/.well-known/oauth-authorization-server", + ]); + const migrated = await service.getConnection(connected.connectionId, company.id); + expect(migrated.config?.oauth).toMatchObject({ + clientIssuer: "https://app.asana.com", clientResource: "https://mcp.asana.com/v2/mcp", + }); + expect(migrated.credentialSecretRefs).toEqual(connection.credentialSecretRefs); + for (const invalidBinding of [ + { clientIssuer: "https://other.example" }, + { clientResource: "https://other.example/mcp" }, + { clientCompanyId: randomUUID() }, + { clientRedirectUri: "https://other.example/api/tools/oauth/callback" }, + ]) { + await db.update(toolConnections).set({ config: { ...connection.config, oauth: { + ...connection.config.oauth as Record, + clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com", + clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback", ...invalidBinding, + } } }).where(eq(toolConnections.id, connection.id)); + await expect(service.startOAuth(company.id, connection.id, { + redirectUri: "http://localhost:3200/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + })).rejects.toMatchObject({ status: 422, details: { code: "oauth_manual_client_rebinding_required" } }); + } + const missingSecret = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", + oauthClient: { clientId: "another-client" }, + }); + await expect(service.startOAuth(company.id, missingSecret.connectionId, { + redirectUri: "http://localhost:3200/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining("requires an OAuth client secret") }); + }); + it("stores a curated customer-owned OAuth client without exposing its secret", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", oauthClient: { clientId: "asana-customer-client", @@ -11653,6 +11733,7 @@ describeEmbeddedPostgres("tool access service", () => { const resumed = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", resumeConnectionId: connected.connectionId, oauthClient: { @@ -11668,11 +11749,101 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it.each([false, true])("finishes a second Asana account with a colliding profile name (installed: %s)", async (installed) => { + const company = await createCompany(db); + const userId = "asana-owner"; + await grantBoardUser(db, company.id, userId, [], "owner"); + const agent = await createAgent(db, company.id); + const actor = { actorType: "user" as const, actorId: userId }; + const credentials = { + v: 1 as const, accessToken: "asana-access", refreshToken: "asana-refresh", tokenType: "Bearer", + accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), + refreshTokenExpiresAt: null, scopes: ["default"], subject: userId, companyId: company.id, + instanceId: "test-instance", environment: "staging" as const, + provider: "asana" as const, profile: "asana.mcp" as const, + }; + const connector: PaperclipCloudConnector = { + getCapabilities: vi.fn(async () => ["asana.mcp"]), + startAuthorization: vi.fn(async ({ returnState }) => ({ + authorizationUrl: `https://app.asana.com/-/oauth_authorize?state=${encodeURIComponent(returnState)}`, + expiresAt: new Date(Date.now() + 600_000).toISOString(), + })), + claim: vi.fn(async () => credentials), refresh: vi.fn(async () => credentials), + revoke: vi.fn(async () => undefined), + }; + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const custom = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana", grantKind: "user", + oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" }, + }, actor); + const first = await service.finishGalleryAppConnection(company.id, custom.connectionId, { + enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [] }, + }, actor); + await service.updateConnection(custom.connectionId, { name: "Asana custom app" }); + const managed = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "managed", name: "Asana", grantKind: "user", + applicationId: custom.application.id, + }, actor); + expect(managed.connection.name).toBe("Asana"); + if (installed) await service.putConnectionInstalls(managed.connectionId, { + installs: [{ targetType: "agent", targetId: agent.id }], + }, actor); + const started = await service.startOAuth(company.id, managed.connectionId, { + redirectUri: "https://tenant.example/api/tools/oauth/cloud-connector/callback", actor, + }); + mockToolsList([{ name: "get_me", annotations: { readOnlyHint: true } }]); + const completed = await service.completePaperclipCloudConnectorCallback({ + state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "asana-claim", actor, + }); + expect(completed.connection).toMatchObject({ status: "active", enabled: true }); + expect(completed.catalog).toEqual([expect.objectContaining({ toolName: "get_me", status: "active" })]); + const repeat = await service.finishGalleryAppConnection(company.id, managed.connectionId, { + enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], + access: { agentIds: installed ? [agent.id] : [] }, + }, actor); + expect(repeat.profile.name).not.toBe(first.profile.name); + expect(repeat.profileBindings.map((binding) => binding.targetId)).toEqual(installed ? [agent.id] : []); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id))).toHaveLength(2); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.id, first.profile.id))) + .toEqual([expect.objectContaining({ name: first.profile.name })]); + }); + + it("resumes personal Asana client credentials with database timestamp precision", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "asana-owner" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user", + oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" }, + }, actor); + const before = await service.listConnectionGrants(connected.connectionId, company.id); + const grant = before.grants[0]!; + await db.update(connectionGrants).set({ updatedAt: sql`'2026-01-01 12:00:00.123456'::timestamp` }) + .where(eq(connectionGrants.id, grant.id)); + await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user", + resumeConnectionId: connected.connectionId, oauthClient: { clientId: "asana-client" }, + }, actor); + const after = await service.listConnectionGrants(connected.connectionId, company.id); + expect(after.grants).toHaveLength(1); + expect(after.grants[0]!.credentialSecretRefs).toEqual(grant.credentialSecretRefs); + expect((await service.getConnection(connected.connectionId, company.id)).credentialSecretRefs).toEqual([]); + expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(true); + expect((await service.listConnections(company.id, actor.actorId))[0].hasSavedOAuthClientSecret).toBe(true); + expect((await service.getConnection(connected.connectionId, company.id, "another-user")).hasSavedOAuthClientSecret).toBe(false); + expect((await service.getConnection(connected.connectionId, company.id)).hasSavedOAuthClientSecret).toBe(false); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grant.id)); + expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false); + await db.update(connectionGrants).set({ status: "active", credentialSecretRefs: [] }).where(eq(connectionGrants.id, grant.id)); + expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false); + }); + it("does not retain a customer OAuth secret when the client id changes", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", oauthClient: { clientId: "asana-first-client", @@ -11682,6 +11853,7 @@ describeEmbeddedPostgres("tool access service", () => { const resumed = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", resumeConnectionId: connected.connectionId, oauthClient: { @@ -15478,18 +15650,8 @@ describeEmbeddedPostgres("tool access service", () => { ), ); - await db.insert(toolProfiles).values({ - companyId: company.id, - profileKey: `conflict-${randomUUID()}`, - name: "Conflicting app profile", - status: "active", - defaultAction: "deny", - }); - await db - .update(toolConnections) - .set({ name: "Conflicting app profile", updatedAt: new Date() }) - .where(eq(toolConnections.id, connect.connectionId)); - + // An absent actor trips the binding's foreign key after profile state is + // replaced. Profile-name collisions are now resolved during finalization. await expect( service.finishGalleryAppConnection( company.id, @@ -15499,7 +15661,7 @@ describeEmbeddedPostgres("tool access service", () => { askFirstCatalogEntryIds: [updateEntry.id], access: { agentIds: [agent.id] }, }, - { actorType: "user", actorId: "board" }, + { actorType: "agent", actorId: randomUUID() }, ), ).rejects.toThrow(); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index f4c51194977..ecf346118b7 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -393,7 +393,7 @@ export function toolAccessRoutes( : null; if ( parsed.host.toLowerCase() === normalizedRoutedHost - && (parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) + && parsed.protocol === "https:" ) { return parsed.origin; } diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 576fc05f5b7..307c9262b51 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -262,6 +262,27 @@ describe("Paperclip Cloud connector", () => { }); }); + it.each([ + ["https://app.asana.com/-/oauth_authorize", true], + ["https://mcp.asana.com/authorize", false], + ["https://app.asana.com/other", false], + ])("validates Asana's exact v2 authorization endpoint %s", async (authorizationUrl, accepted) => { + const keys = config(); + const connector = createPaperclipCloudConnector({ config: keys.config, + request: vi.fn(async (_url, init) => { + const body = JSON.parse(String(init?.body)); + const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8")); + expect(claims).toMatchObject({ prv: "asana", prf: "asana.mcp", scp: ["default"] }); + return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=test", + authorizationUrl, expiresAt: "2099-01-01T00:00:00Z" }); + }) as typeof fetch, + }); + const result = connector.startAuthorization({ subject, companyId, profile: "asana.mcp", + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "state" }); + if (accepted) await expect(result).resolves.toMatchObject({ authorizationUrl }); + else await expect(result).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); + }); + it("accepts the fixed Google authorization endpoint for Google profiles", async () => { const keys = config(); const connector = createPaperclipCloudConnector({ diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 677d64fb1d6..fb52bed38ca 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -1,3 +1,4 @@ +import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { createDecipheriv, createHash, @@ -33,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack"; -export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId; -export type PaperclipCloudConnectorProvider = "google" | "github"; +export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId; +export type PaperclipCloudConnectorProvider = "google" | "github" | "asana"; export type PaperclipCloudConnectorConfig = { baseUrl: string; @@ -754,6 +755,9 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): provider: PaperclipCloudConnectorProvider; scopes: readonly string[]; } { + if (isAsanaConnectorProfileId(profile)) { + return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; + } if (isGitHubConnectorProfileId(profile)) { return { provider: "github", scopes: GITHUB_CONNECTOR_PROFILES[profile].scopes }; } @@ -764,6 +768,9 @@ function isExpectedProviderAuthorizationUrl( profile: PaperclipCloudConnectorProfileId, url: URL, ): boolean { + if (isAsanaConnectorProfileId(profile)) { + return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize"; + } if (isGitHubConnectorProfileId(profile)) { return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize"; } @@ -771,7 +778,7 @@ function isExpectedProviderAuthorizationUrl( } function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId { - return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value); + return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); } async function sha256Base64Url(value: string): Promise { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 982a0e82bce..08d64725329 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,4 +1,5 @@ import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; +import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.js"; import { browserUseService } from "./browser-use.js"; @@ -2491,13 +2492,16 @@ export function isGoogleWorkspaceToolAllowed( } type ManagedConnectorProfileId = - GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId; + GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId; function managedConnectorProfile(value: string | undefined): { id: ManagedConnectorProfileId; - provider: "google" | "github"; + provider: "google" | "github" | "asana"; scopes: readonly string[]; } | null { + if (value && isAsanaConnectorProfileId(value)) { + return { id: value, provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; + } if (value && isGoogleWorkspaceConnectorProfileId(value)) { return { id: value, @@ -8402,6 +8406,27 @@ export function toolAccessService( return oauthClientConfig(provider); } + async function annotateSavedOAuthClientSecret(connections: ToolConnection[], viewerUserId?: string) { + const personal = connections.filter((connection) => connection.authKind === "oauth" && connection.credentialPolicy === "per_user"); + const grants = viewerUserId && personal.length > 0 + ? await db.select({ connectionId: connectionGrants.connectionId, refs: connectionGrants.credentialSecretRefs }) + .from(connectionGrants) + .where(and( + inArray(connectionGrants.companyId, [...new Set(personal.map((connection) => connection.companyId))]), + inArray(connectionGrants.connectionId, personal.map((connection) => connection.id)), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, viewerUserId), + eq(connectionGrants.status, "active"), + )) + : []; + const savedPersonal = new Set(grants.filter((grant) => grant.refs.some((ref) => ref.configPath === "oauth.client_secret")).map((grant) => grant.connectionId)); + for (const connection of connections) { + connection.hasSavedOAuthClientSecret = connection.credentialPolicy === "per_user" + ? savedPersonal.has(connection.id) + : connection.credentialSecretRefs.some((ref) => ref.configPath === "oauth.client_secret"); + } + } + async function oauthClientForConnection( connection: typeof toolConnections.$inferSelect, provider: string, @@ -9030,6 +9055,17 @@ export function toolAccessService( connection.ownership !== "dcr" && typeof storedOAuth.clientId === "string" && storedOAuth.clientId.trim().length > 0; + // A reviewed protected-resource document is authoritative for this method. + // Some hosts still serve metadata for a retired MCP server at the root. + // Resolve it before cached endpoints, including on existing draft reconnects. + if (!smokeLabEndpoints && galleryMethod?.defaults?.discoveryUrl) { + const endpoints = await endpointsFromMetadataUrl( + connection, galleryMethod.defaults.discoveryUrl, [], originOf(redirectUri), + ); + if (!endpoints) throw unprocessable("OAuth provider metadata could not be loaded"); + assertNotSmokeLabOAuthEndpoints(connection, endpoints); + return endpoints; + } const hasCompleteGalleryEndpointHints = Boolean( galleryMethod?.defaults?.authorizationEndpoint && galleryMethod.defaults.tokenEndpoint && @@ -9637,13 +9673,34 @@ export function toolAccessService( * reusing them across a moved binding would let a re-pointed endpoint borrow * another server's registration. */ + function reviewedOAuthClientBinding(connection: typeof toolConnections.$inferSelect, endpoints: OAuthProviderEndpoints) { + const oauth = oauthConfig(connection); + // Only repair the known Asana v1 discovery mistake. Retain company and + // callback checks, and never carry a secret to an arbitrary new issuer. + if (connection.config.sourceTemplateKey === "asana" + && connection.config.connectionMethodKey === "mcp-own-oauth" + && connection.config.url === "https://mcp.asana.com/v2/mcp" + && oauth.clientRegistrationSource === "manual" + && connection.ownership !== "dcr" + && endpoints.issuer === "https://app.asana.com" + && endpoints.authorizationUrl === "https://app.asana.com/-/oauth_authorize" + && endpoints.tokenUrl === "https://app.asana.com/-/oauth_token" + && endpoints.resource === "https://mcp.asana.com/v2/mcp" + && [undefined, null, "https://mcp.asana.com", "https://app.asana.com"].includes(oauth.clientIssuer as string | null | undefined) + && [undefined, null, "https://mcp.asana.com", "https://mcp.asana.com/v2/mcp"].includes(oauth.clientResource as string | null | undefined) + && (oauth.clientIssuer === "https://mcp.asana.com" || oauth.clientResource === "https://mcp.asana.com")) { + return { ...oauth, clientIssuer: endpoints.issuer, clientResource: endpoints.resource }; + } + return oauth; + } + function oauthClientBindingMatches( connection: typeof toolConnections.$inferSelect, endpoints: OAuthProviderEndpoints, redirectUri: string, clientIdMetadataDocumentUrl: string | null, ): boolean { - const oauth = oauthConfig(connection); + const oauth = reviewedOAuthClientBinding(connection, endpoints); if (typeof oauth.clientId !== "string" || !oauth.clientId.trim()) return false; const source = @@ -9708,7 +9765,8 @@ export function toolAccessService( endpoints: OAuthProviderEndpoints, redirectUri: string, ): Promise { - const oauth = oauthConfig(connection); + const originalOAuth = oauthConfig(connection); + const oauth = reviewedOAuthClientBinding(connection, endpoints); const nextBinding = { clientRedirectUri: redirectUri, clientIssuer: @@ -9725,7 +9783,7 @@ export function toolAccessService( : connection.companyId, }; const unchanged = Object.entries(nextBinding).every( - ([key, value]) => oauth[key] === value, + ([key, value]) => originalOAuth[key] === value, ); if (unchanged) return connection; const nextConfig = { @@ -13339,6 +13397,19 @@ export function toolAccessService( ), ) .limit(1); + // A renamed connection can leave its old profile name in use. Resolve + // names against profiles too, including when finalizing an installed draft. + const otherProfiles = await tx + .select({ name: toolProfiles.name }) + .from(toolProfiles) + .where(and( + eq(toolProfiles.companyId, companyId), + ne(toolProfiles.profileKey, profileKey), + )); + const profileName = nextAvailableConnectionName( + connection.name, + otherProfiles.map((profile) => profile.name), + ); let profileId: string; if (existingProfile) { if (input.preserveExistingAccess) { @@ -13418,7 +13489,7 @@ export function toolAccessService( const [updated] = await tx .update(toolProfiles) .set({ - name: connection.name, + name: profileName, description: `Access profile for ${connection.name}.`, status: "active", defaultAction: "deny", @@ -13437,7 +13508,7 @@ export function toolAccessService( .values({ companyId, profileKey, - name: connection.name, + name: profileName, description: `Access profile for ${connection.name}.`, status: "active", defaultAction: "deny", @@ -14137,7 +14208,9 @@ export function toolAccessService( issuer: managedProfile.provider === "github" ? "https://github.com" - : "https://accounts.google.com", + : managedProfile.provider === "asana" + ? "https://app.asana.com" + : "https://accounts.google.com", resource: galleryMethod.defaults?.serverUrl ?? null, registrationSource: null, }; @@ -14161,6 +14234,9 @@ export function toolAccessService( }); connection = resolvedClient.connection; const client = resolvedClient.client; + if (galleryMethod?.oauthClientSecretRequired && !client.clientSecret) { + throw unprocessable("This app requires an OAuth client secret. Add it in the app setup before signing in."); + } if (!client.clientId) throw unprocessable( `OAuth client id is not configured for ${endpoints.provider}`, @@ -14765,9 +14841,9 @@ export function toolAccessService( redemptionId: input.state, }); const refreshToken = credentials.refreshToken; - if (profile.provider === "google" && !refreshToken) { + if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) { throw unprocessable( - `Google did not return offline access. Reconnect ${providerName} and grant the requested scopes.`, + `${providerName} did not return offline access. Reconnect and grant the requested permissions.`, { code: "oauth_refresh_missing", }, @@ -17180,6 +17256,7 @@ export function toolAccessService( } await annotateAiGrantHealth(connections); await annotateGitHubAuthorization(connections, viewerUserId); + await annotateSavedOAuthClientSecret(connections, viewerUserId); return connections; }, @@ -17284,6 +17361,7 @@ export function toolAccessService( ); await annotateAiGrantHealth([connection]); await annotateGitHubAuthorization([connection], viewerUserId); + await annotateSavedOAuthClientSecret([connection], viewerUserId); return connection; }, diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 2562372c366..7a01867fc7a 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -979,7 +979,12 @@ function StandardConnectionSetupFlow({ const fullRequestedDefinition = requestedAppKey ? getConnectableAppDefinition(requestedAppKey) : null; - const requestedDefinitionUsesManagedConnector = Boolean( + const customOAuthMethod = fullRequestedDefinition?.methods.find((candidate) => + connectionMethodAcceptsCustomerOAuthClient(candidate) + && !connectionMethodSupportsAutomaticOAuth(candidate), + ); + const selectedCustomOAuth = Boolean(customOAuthMethod && connectionMethodKey === customOAuthMethod.key); + const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && Boolean( fullRequestedDefinition?.methods.some((candidate) => candidate.oauthStrategy === "paperclip_cloud_connector" || candidate.oauthStrategy === "paperclip_id_connector" @@ -1613,14 +1618,16 @@ function StandardConnectionSetupFlow({ }, [resumeConnectionId]); useEffect(() => { + const savedConnection = resumeConnection ?? reconnectConnection; + const savedOAuth = savedConnection?.config?.oauth as Record | undefined; if ( - !resumeConnection + !savedConnection || !entry - || resumeConnection.status !== "draft" - || hydratedResumeConnectionIdRef.current === resumeConnection.id + || (savedConnection.status !== "draft" && savedOAuth?.clientRegistrationSource !== "manual") + || hydratedResumeConnectionIdRef.current === savedConnection.id ) return; - const storedConfig = resumeConnection.config && typeof resumeConnection.config === "object" - ? resumeConnection.config + const storedConfig = savedConnection.config && typeof savedConnection.config === "object" + ? savedConnection.config : {}; const storedSource = typeof storedConfig.sourceTemplateKey === "string" ? storedConfig.sourceTemplateKey @@ -1635,7 +1642,7 @@ function StandardConnectionSetupFlow({ (candidate) => candidate.key === storedMethodKey, ) ?? null : null; - setGalleryName(resumeConnection.name || entry.name); + setGalleryName(savedConnection.name || entry.name); if (resumedMethod) { setConnectionMethodKey(resumedMethod.key); const storedMethodConfig = storedConfig.methodConfig && typeof storedConfig.methodConfig === "object" @@ -1676,9 +1683,9 @@ function StandardConnectionSetupFlow({ setOAuthError(null); } setStep("key"); - hydratedResumeConnectionIdRef.current = resumeConnection.id; - setHydratedResumeConnectionId(resumeConnection.id); - }, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection]); + hydratedResumeConnectionIdRef.current = savedConnection.id; + setHydratedResumeConnectionId(savedConnection.id); + }, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection, reconnectConnection]); /** * Commit the connection: action defaults, agent reach, and installs. @@ -1971,7 +1978,7 @@ function StandardConnectionSetupFlow({ ) ) : null; // A provider can advertise registration and still refuse this deployment's - // callback (Asana refuses hosted ones). When the method also accepts an + // callback. 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 @@ -2295,6 +2302,11 @@ 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.

+ {customOAuthMethod ? ( + + ) : null}
+ {customOAuthMethod ? ( + + ) : null} + {connectorEnrollmentQuery.isError || connectorEnrollmentError ? ( {connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."} @@ -2368,6 +2386,11 @@ function StandardConnectionSetupFlow({ oauthClientId={curatedOAuthClientId} onOAuthClientIdChange={setCuratedOAuthClientId} oauthClientSecret={curatedOAuthClientSecret} + canReuseOAuthClientSecret={Boolean( + identityConnection?.config?.oauth + && (identityConnection.config.oauth as Record).clientId === curatedOAuthClientId.trim() + && identityConnection.hasSavedOAuthClientSecret + )} onOAuthClientSecretChange={setCuratedOAuthClientSecret} credentialSource={credentialSource} vercelConnector={vercelConnector} @@ -3401,6 +3424,7 @@ function KeyStep({ oauthClientId, onOAuthClientIdChange, oauthClientSecret, + canReuseOAuthClientSecret, onOAuthClientSecretChange, credentialSource, vercelConnector, @@ -3425,6 +3449,7 @@ function KeyStep({ oauthClientId: string; onOAuthClientIdChange: (next: string) => void; oauthClientSecret: string; + canReuseOAuthClientSecret: boolean; onOAuthClientSecretChange: (next: string) => void; credentialSource: ToolConnectionCredentialSource; vercelConnector: string; @@ -3515,7 +3540,10 @@ function KeyStep({ const acceptsCustomerOAuthClient = connectionMethodAcceptsCustomerOAuthClient(method); const customerOAuthClientRequired = acceptsCustomerOAuthClient && !connectionMethodSupportsAutomaticOAuth(method); - const oauthClientFilled = usingVercel || !customerOAuthClientRequired || oauthClientId.trim().length > 0; + const oauthClientFilled = usingVercel || !customerOAuthClientRequired || ( + oauthClientId.trim().length > 0 + && (!method?.oauthClientSecretRequired || canReuseOAuthClientSecret || oauthClientSecret.trim().length > 0) + ); const vercelConnectorFilled = !usingVercel || vercelConnector.trim().length > 0; const oauthCallbackUrl = method?.auth === "oauth" && acceptsCustomerOAuthClient ? oauthCallbackUrlForBrowser() @@ -3565,27 +3593,26 @@ function KeyStep({ {!capabilityKey &&

Choose an access level to continue.

} ) : null; - const managedGoogleMethod = capabilityMethods.find((candidate) => - candidate.oauthStrategy === "paperclip_cloud_connector" - && isGoogleWorkspaceConnectorProfileId(candidate.connectorProfile ?? ""), + const managedOAuthMethod = capabilityMethods.find((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector", ); - const customerGoogleMethod = managedGoogleMethod && capabilityMethods.find((candidate) => + const customerOAuthMethod = managedOAuthMethod && capabilityMethods.find((candidate) => connectionMethodAcceptsCustomerOAuthClient(candidate) && !connectionMethodSupportsAutomaticOAuth(candidate), ); - const usingCustomGoogleOAuth = method?.key === customerGoogleMethod?.key; - const googleOAuthFieldsId = useId(); - const authenticationSelection = managedGoogleMethod && customerGoogleMethod && capabilityMethods.length === 2 ? ( + const usingCustomOAuth = method?.key === customerOAuthMethod?.key; + const customOAuthFieldsId = useId(); + const authenticationSelection = managedOAuthMethod && customerOAuthMethod && capabilityMethods.length === 2 ? ( ) : capabilityMethods.length > 1 ? ( // PAP-659 C1: the ranked default is already selected. This stays as the @@ -3614,7 +3641,7 @@ function KeyStep({ || 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 forceAdvancedOpen = usingCustomOAuth || !hasMethodSelection; const advancedSettings = hasAdvancedSettings ? (
{capabilitySelection} @@ -3705,7 +3732,8 @@ function KeyStep({ ); } - const requirementsUrl = method?.consoleLinks?.docs ?? entry.docsUrl; + const managedAsana = method?.connectorProfile === "asana.mcp"; + const requirementsUrl = managedAsana ? null : method?.consoleLinks?.docs ?? entry.docsUrl; return (
@@ -3724,6 +3752,9 @@ function KeyStep({ ) : null}
+ {managedAsana ? ( +

Sign in with Asana to choose your workspace and connect it to Paperclip.

+ ) : null} {method?.capabilityProfile && (

{method.capabilityProfile.label}: {method.capabilityProfile.description} @@ -3778,7 +3809,7 @@ function KeyStep({ {!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? ( -

+
- {required ? `${entry.name} needs its own OAuth app` : "Use your own OAuth app"} + {required ? method.oauthClientSecretRequired ? "Your OAuth app" : `${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 @@ -3900,7 +3931,7 @@ function OAuthClientFields({ exception it is rather than as this connector's normal path. */}

- {required + {method.oauthClientSecretRequired ? method.guidanceMd : 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.`}

@@ -3956,7 +3987,7 @@ function OAuthClientFields({ value={clientSecret} onChange={(event) => onClientSecretChange(event.target.value)} autoComplete="off" - placeholder="Optional for public clients" + placeholder={method.oauthClientSecretRequired ? "Required" : "Optional for public clients"} className="mt-2 h-11 font-mono" />
diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index aad46401c4d..82309bf53ee 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -53,6 +53,10 @@ const GITHUB_MANAGED = { }; const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!; const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!; +const ASANA_MANAGED = { + ...ASANA, + ownershipAvailability: { ...ASANA.ownershipAvailability, platform_shared: true }, +}; 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")!; @@ -981,6 +985,45 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(gmail?.textContent).not.toContain("Coming soon"); }); + it("defaults Asana to managed sign-in and allows a custom app before enrollment", async () => { + mockSearch.value = "source=asana"; + listGalleryMock.mockResolvedValue({ apps: [{ ...ASANA, methods: ASANA.methods.filter((method) => !method.oauthStrategy), ownershipAvailability: { + ...ASANA.ownershipAvailability, platform_shared: false, + } }] }); + getCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, status: "not_configured", origins: [] }); + await render(); + await passAccessStep(); + expect(container.textContent).toContain("Connect with Paperclip"); + await act(async () => { buttonByText("Use your own Asana OAuth app")!.click(); }); + await flushReact(); + expect(container.textContent).toContain("Your OAuth app"); + expect(container.textContent).toContain("API apps do not work with Asana MCP"); + expect(container.textContent).not.toContain("You must connect this instance"); + await act(async () => { setInputValue(container.querySelector("#curated-oauth-client-id")!, "asana-client"); }); + await flushReact(); + expect(buttonByText("Continue to sign in")?.disabled).toBe(true); + expect(startCloudConnectorEnrollmentMock).not.toHaveBeenCalled(); + }); + + it.each([true, false])("requires the acting user's saved Asana client secret for reuse (%s)", async (hasSavedOAuthClientSecret) => { + mockSearch.value = "source=asana&resume=conn-asana"; + listGalleryMock.mockResolvedValue({ apps: [ASANA] }); + listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-asana", status: "draft", metadata: { sourceTemplateKey: "asana" } }] }); + listConnectionsMock.mockResolvedValue({ connections: [{ + id: "conn-asana", applicationId: "app-asana", name: "Asana", authKind: "oauth", + credentialPolicy: "per_user", status: "draft", credentialSecretRefs: [], hasSavedOAuthClientSecret, + config: { sourceTemplateKey: "asana", connectionMethodKey: "mcp-own-oauth", oauth: { clientId: "saved-asana-client", clientRegistrationSource: "manual" } }, + transportConfig: {}, + }] }); + await render(); + await flushReact(); + expect(container.querySelector("#curated-oauth-client-id")?.value).toBe("saved-asana-client"); + expect(buttonByText("Continue to sign in")?.disabled).toBe(!hasSavedOAuthClientSecret); + await act(async () => { setInputValue(container.querySelector("#curated-oauth-client-id")!, "different-client"); }); + await flushReact(); + expect(buttonByText("Continue to sign in")?.disabled).toBe(true); + }); + it("collects customer-owned OAuth client details for a curated manual OAuth app", async () => { listGalleryMock.mockResolvedValue({ apps: [BOX] }); mockParams.appKey = "box"; @@ -1023,28 +1066,27 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { })); }); - it("gives Asana the one-click path now that its server advertises registration", async () => { - listGalleryMock.mockResolvedValue({ apps: [ASANA] }); + it("connects Asana through the shared app without client credentials", async () => { + listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] }); 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"), - ); + const primary = buttonByText("Continue to sign in"); expect(primary).toBeTruthy(); expect(primary?.disabled).toBe(false); + await act(async () => { primary!.click(); }); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "asana", connectionMethodKey: "managed", grantKind: "user", + })); }); - it("offers Asana's own-OAuth-app fields as the recovery when registration is refused", async () => { - // Asana advertises registration but refuses hosted callbacks, so a failed - // sign-in must still leave the customer-owned client path within reach. - listGalleryMock.mockResolvedValue({ apps: [ASANA] }); + it("allows a custom Asana app from the shared sign-in setup", async () => { + listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] }); mockParams.appKey = "asana"; - connectAppMock.mockRejectedValueOnce(new Error("Asana refused the redirect URI.")); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-asana", application: { id: "app-asana", name: "Asana" }, @@ -1058,25 +1100,28 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); - await act(async () => { - buttonByText("Continue to Asana")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + await openAccessAdvanced(); await flushReact(); + const customApp = buttonByText("Use your own Asana OAuth app"); + expect(customApp).toBeTruthy(); + await act(async () => { customApp!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const clientId = container.querySelector("#curated-oauth-client-id"); + const clientSecret = container.querySelector("#curated-oauth-client-secret"); expect(clientId).toBeTruthy(); - await act(async () => setInputValue(clientId!, "asana-own-client")); - await flushReact(); + expect(clientSecret).toBeTruthy(); await act(async () => { - buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + setInputValue(clientId!, "asana-own-client"); + setInputValue(clientSecret!, "asana-own-secret"); }); await flushReact(); + await act(async () => { buttonByText("Continue to sign in")!.click(); }); await flushReact(); expect(connectAppMock).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ - galleryKey: "asana", - oauthClient: expect.objectContaining({ clientId: "asana-own-client" }), + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", + oauthClient: { clientId: "asana-own-client", clientSecret: "asana-own-secret" }, })); }); diff --git a/ui/src/pages/apps/app-connect-policy.test.ts b/ui/src/pages/apps/app-connect-policy.test.ts index e4a5fa1b7e6..a11c224f411 100644 --- a/ui/src/pages/apps/app-connect-policy.test.ts +++ b/ui/src/pages/apps/app-connect-policy.test.ts @@ -15,14 +15,11 @@ describe("app connect policy", () => { expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(expect.arrayContaining(["jira", "notion", "sentry"])); expect(isMcpDirectOAuthConnectSlug("notion")).toBe(true); expect(isMcpDirectOAuthConnectSlug("jira")).toBe(true); - // PAP-659 step 4: Asana's own OAuth metadata advertises registration, and - // live discovery now outranks its pinned customer-only ownership mode, so - // it reaches the provider directly instead of the client-ID form. - expect(isMcpDirectOAuthConnectSlug("asana")).toBe(true); - // Still false, and for two different reasons worth keeping apart: GitHub's - // one-click path is Paperclip-managed rather than direct, and Slack really - // does require a customer-registered OAuth client. + // Asana and GitHub need an available Paperclip-managed profile for + // one-click sign-in. The static catalog defaults to their custom-app path. + expect(isMcpDirectOAuthConnectSlug("asana")).toBe(false); expect(isMcpDirectOAuthConnectSlug("github")).toBe(false); + // Slack requires a customer-registered OAuth client. expect(isMcpDirectOAuthConnectSlug("slack")).toBe(false); expect(isMcpDirectOAuthConnectSlug(null)).toBe(false); });