diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index a8024b5798..2c05876145 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -357,3 +357,7 @@ Linear's real-vendor evidence belongs in [PAP-12373](/PAP/issues/PAP-12373). The - A call against a disallowed team/project is denied. - Revocation removes Linear tools and blocks execution. - Audit rows include company, connection, run/issue, agent/user actor, tool, decision, reason code, and outcome. +### AppDefinition catalog authoring + +Connector proposals now target the versioned `AppDefinition` contract in `packages/shared/src/types/app-definition.ts`. Seed data is one JSON file per provider under `packages/shared/src/app-definitions/`; regenerate Wave 1 with `pnpm connections:ingest-app-definitions`. The generator parses all 99 captured templates, validates required placeholders, OAuth ownership modes, and API-key placement, and produces deterministic output for review. FIRST-30 remains authoritative for `riskTier` and `requiredResourceFilters`; managed ownership modes stay data-visible but runtime-hidden until availability is injected. + diff --git a/package.json b/package.json index 3eb37338b5..93699bd2db 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", "metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts", - "perf:issue-chat-long-thread": "node scripts/measure-issue-chat-long-thread.mjs" + "perf:issue-chat-long-thread": "node scripts/measure-issue-chat-long-thread.mjs", + "connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs" }, "devDependencies": { "@playwright/test": "^1.61.1", diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts new file mode 100644 index 0000000000..24386db0a7 --- /dev/null +++ b/packages/shared/src/app-definitions-url.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + getAppDefinitionForUrl, + CONNECTABLE_APP_DEFINITIONS, +} from "./app-definitions.js"; + +describe("tool app gallery URL matching", () => { + it("matches pasted links against gallery URL patterns", () => { + expect(getAppDefinitionForUrl("https://mcp.zapier.com/api/mcp")?.slug).toBe("zapier"); + expect(getAppDefinitionForUrl("https://api.githubcopilot.com/mcp/")?.slug).toBe("github"); + expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets"); + }); + + it("returns null for invalid or unknown links", () => { + expect(getAppDefinitionForUrl("not a url")).toBeNull(); + expect(getAppDefinitionForUrl("https://example.com/mcp")).toBeNull(); + expect(getAppDefinitionForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull(); + }); + + it("does not list Google Drive until its OAuth client flow is supported", () => { + expect(CONNECTABLE_APP_DEFINITIONS.map((app) => app.slug)).not.toContain("google-drive"); + expect(getAppDefinitionForUrl("https://mcp.google.com/drive")).toBeNull(); + }); + + it("keeps every gallery entry reachable through at least one pattern", () => { + for (const app of CONNECTABLE_APP_DEFINITIONS) { + const example = app.urlPatterns[0]?.replace("*", "example"); + expect(example, `${app.slug} has a pattern`).toBeTruthy(); + expect(getAppDefinitionForUrl(example!)?.slug).toBe(app.slug); + } + }); +}); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts new file mode 100644 index 0000000000..a306bef695 --- /dev/null +++ b/packages/shared/src/app-definitions.generated.ts @@ -0,0 +1,14 @@ +import a0 from "./app-definitions/zapier.json" with { type: "json" }; +import a1 from "./app-definitions/github.json" with { type: "json" }; +import a2 from "./app-definitions/slack.json" with { type: "json" }; +import a3 from "./app-definitions/notion.json" with { type: "json" }; +import a4 from "./app-definitions/linear.json" with { type: "json" }; +import a5 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a6 from "./app-definitions/context7.json" with { type: "json" }; +import a7 from "./app-definitions/oauth-generic.json" with { type: "json" }; +import a8 from "./app-definitions/api-key-generic.json" with { type: "json" }; +import a9 from "./app-definitions/sentry.json" with { type: "json" }; +import a10 from "./app-definitions/vercel.json" with { type: "json" }; +import a11 from "./app-definitions/anthropic.json" with { type: "json" }; +import type { AppDefinition } from "./types/app-definition.js"; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.ingestion-report.json b/packages/shared/src/app-definitions.ingestion-report.json new file mode 100644 index 0000000000..4796938b1a --- /dev/null +++ b/packages/shared/src/app-definitions.ingestion-report.json @@ -0,0 +1,2525 @@ +{ + "schemaVersion": 1, + "corpusSize": 99, + "providers": [ + { + "slug": "airtable", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, Advanced expanded)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, Advanced expanded)" + ] + }, + { + "slug": "anthropic", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "api-key-generic", + "stateCount": 1, + "states": [ + { + "label": "initial — Step 2 Configure (single state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 2 + } + ], + "ambiguities": [] + }, + { + "slug": "asana", + "stateCount": 1, + "states": [ + { + "label": "initial (manual/BYO only — no managed setup offered)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "platform_shared", + "customer" + ], + "fieldCount": 8, + "linkCount": 5 + } + ], + "ambiguities": [] + }, + { + "slug": "bamboohr", + "stateCount": 2, + "states": [ + { + "label": "initial — Step 2 Configure — Select Connection Type (OAuth 2.0 card open with Company Domain; API Key card collapsed)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 1, + "linkCount": 0 + }, + { + "label": "Step 2 Configure — after clicking \"API Key\" connection-type card", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 3 + } + ], + "ambiguities": [] + }, + { + "slug": "beehiiv", + "stateCount": 5, + "states": [ + { + "label": "initial — Step 2 Details (radio MCP | API, MCP default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 7, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 9, + "linkCount": 4 + }, + { + "label": "Step 3 Configure — path: API — auth: OAuth 2.0 (default; captured in a fresh dialog, name orange-chair)", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 7, + "linkCount": 5 + }, + { + "label": "Step 3 Configure — path: API — auth: API Key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 10, + "linkCount": 3 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Details (radio MCP | API, MCP default)", + "Auth is not explicit in capture state: Step 3 Configure — path: MCP — tab: Managed (default)" + ] + }, + { + "slug": "bitly", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "box", + "stateCount": 1, + "states": [ + { + "label": "initial (manual/BYO only — no managed setup offered)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "platform_shared", + "customer" + ], + "fieldCount": 8, + "linkCount": 5 + } + ], + "ambiguities": [] + }, + { + "slug": "brex", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "calendly", + "stateCount": 3, + "states": [ + { + "label": "initial — Select Connection Type", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 0, + "linkCount": 0 + }, + { + "label": "OAuth 2.0 selected", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "API Key selected", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Select Connection Type" + ] + }, + { + "slug": "candid", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "canva", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth form, only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "circleci", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "clerk", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "clickhouse", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "clickup", + "stateCount": 3, + "states": [ + { + "label": "initial — Select Connection Type", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 0, + "linkCount": 0 + }, + { + "label": "OAuth 2.0 selected", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "API Key selected", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Select Connection Type" + ] + }, + { + "slug": "cloudflare", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "cloudinary", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "coda", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "cohere", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "contentful", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "convex", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth form, only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "crowdin", + "stateCount": 3, + "states": [ + { + "label": "initial — Select Connection Type", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 0, + "linkCount": 0 + }, + { + "label": "OAuth 2.0 selected", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "API Key selected", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Select Connection Type" + ] + }, + { + "slug": "deepgram", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "deepseek", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "discord", + "stateCount": 1, + "states": [ + { + "label": "initial — Step 2 Configure (single state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 5 + } + ], + "ambiguities": [] + }, + { + "slug": "dovetail", + "stateCount": 1, + "states": [ + { + "label": "initial — Step 2 Configure (API Key; only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "dropbox", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth form, only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "egnyte", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "elevenlabs", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "embat", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "figma", + "stateCount": 1, + "states": [ + { + "label": "initial — Step 2 Configure (OAuth, customer-owned credentials; only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 7, + "linkCount": 2 + } + ], + "ambiguities": [] + }, + { + "slug": "firecrawl", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "g2", + "stateCount": 1, + "states": [ + { + "label": "initial (manual/BYO only — no managed setup offered)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "platform_shared", + "customer" + ], + "fieldCount": 8, + "linkCount": 5 + } + ], + "ambiguities": [] + }, + { + "slug": "github", + "stateCount": 2, + "states": [ + { + "label": "Step 2 Configure — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 3, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 10, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: Step 2 Configure — tab: Managed (default)" + ] + }, + { + "slug": "gitlab", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "google-gemini", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key form, only state)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "google", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth form, only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "hubspot", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth form, only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "hugging-face", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "intercom", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth form, only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "jira", + "stateCount": 4, + "states": [ + { + "label": "initial — Step 2 Details (radio MCP | API, MCP default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: API — auth: OAuth 2.0 (only auth option)", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 6 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Details (radio MCP | API, MCP default)", + "Auth is not explicit in capture state: Step 3 Configure — path: MCP — tab: Managed (default)" + ] + }, + { + "slug": "kernel", + "stateCount": 4, + "states": [ + { + "label": "initial — Step 2 Details (radio MCP | API, MCP default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: API — auth: API Key (only auth option; captured in a fresh dialog, name fuchsia-ribbon)", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 9, + "linkCount": 4 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Details (radio MCP | API, MCP default)", + "Auth is not explicit in capture state: Step 3 Configure — path: MCP — tab: Managed (default)" + ] + }, + { + "slug": "linear", + "stateCount": 2, + "states": [ + { + "label": "Step 2 Configure — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 2, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 7, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: Step 2 Configure — tab: Managed (default)" + ] + }, + { + "slug": "linkedin", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth configure — only state; no connection-type chooser)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 5 + } + ], + "ambiguities": [] + }, + { + "slug": "local-falcon", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 5, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 7, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "mailgun", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 2 + } + ], + "ambiguities": [] + }, + { + "slug": "make", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 5, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 7, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "manufact", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "mem0", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "miro", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 5, + "linkCount": 2 + }, + { + "label": "manual (after \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 7, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup)" + ] + }, + { + "slug": "mixpanel", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 5, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 7, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "monday", + "stateCount": 3, + "states": [ + { + "label": "initial (Select Connection Type)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 0, + "linkCount": 0 + }, + { + "label": "OAuth 2.0 selected", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 4 + }, + { + "label": "API Key selected", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (Select Connection Type)" + ] + }, + { + "slug": "neon", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "netlify", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "ngrok", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "notion", + "stateCount": 5, + "states": [ + { + "label": "initial — Step 2 Details (radio MCP | API, MCP default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: API — auth: OAuth 2.0 (default)", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 7, + "linkCount": 5 + }, + { + "label": "Step 3 Configure — path: API — auth: API Key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 10, + "linkCount": 4 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Details (radio MCP | API, MCP default)", + "Auth is not explicit in capture state: Step 3 Configure — path: MCP — tab: Managed (default)" + ] + }, + { + "slug": "oauth-generic", + "stateCount": 2, + "states": [ + { + "label": "initial — Step 2 Configure (managed/auto-discover view)", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [ + "platform_shared", + "dcr" + ], + "fieldCount": 1, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — \"enter details manually\" view, Advanced expanded", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 10, + "linkCount": 2 + } + ], + "ambiguities": [] + }, + { + "slug": "okta", + "stateCount": 1, + "states": [ + { + "label": "initial — Step 2 Configure (single state, both connection-type cards inline)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Configure (single state, both connection-type cards inline)" + ] + }, + { + "slug": "openai", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "openrouter", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "oreilly", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "pagerduty", + "stateCount": 1, + "states": [ + { + "label": "initial (opens directly in manual OAuth-app mode; no managed option)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "platform_shared", + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "perplexity", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "pinecone", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "planetscale", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 5, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 7, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "posthog", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "postman", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "railway", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "razorpay", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "render", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "replicate", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "resend", + "stateCount": 6, + "states": [ + { + "label": "initial — Step 2 Details (radio MCP | API, MCP default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 7, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 9, + "linkCount": 4 + }, + { + "label": "Step 3 Configure — path: API — auth: OAuth 2.0 (default, auto-registered client)", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: API — auth: OAuth 2.0 — after \"enter details manually\"", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 4 + }, + { + "label": "Step 3 Configure — path: API — auth: API Key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 10, + "linkCount": 4 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Details (radio MCP | API, MCP default)", + "Auth is not explicit in capture state: Step 3 Configure — path: MCP — tab: Managed (default)" + ] + }, + { + "slug": "salesforce", + "stateCount": 3, + "states": [ + { + "label": "initial — Step 2 Configure (managed package install view)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 0, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — managed view after clicking \"change\" (domain editor)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 1, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — \"enter details manually\" view", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "customer" + ], + "fieldCount": 1, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Configure (managed package install view)", + "Auth is not explicit in capture state: Step 2 Configure — managed view after clicking \"change\" (domain editor)", + "Auth is not explicit in capture state: Step 2 Configure — \"enter details manually\" view" + ] + }, + { + "slug": "sanity", + "stateCount": 5, + "states": [ + { + "label": "initial — Step 2 Details (radio MCP | API, MCP default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: API — auth: OAuth 2.0 (default)", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 7, + "linkCount": 5 + }, + { + "label": "Step 3 Configure — path: API — auth: API Key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 10, + "linkCount": 4 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Details (radio MCP | API, MCP default)", + "Auth is not explicit in capture state: Step 3 Configure — path: MCP — tab: Managed (default)" + ] + }, + { + "slug": "segment", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "sendgrid", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "sentry", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "shopify", + "stateCount": 1, + "states": [ + { + "label": "initial — Step 2 Configure — Select Connection Type (OAuth 2.0 only; only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 1, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "similarweb", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "slack", + "stateCount": 2, + "states": [ + { + "label": "Step 2 Configure — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 2, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 9, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: Step 2 Configure — tab: Managed (default)" + ] + }, + { + "slug": "snowflake", + "stateCount": 3, + "states": [ + { + "label": "initial — Step 2 Configure — strategy selection", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 0, + "linkCount": 0 + }, + { + "label": "Step 2 Configure — strategy: OAuth (Using Snowflake Partner Connect)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 4, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — strategy: Workload Identity Federation", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 1, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Configure — strategy selection", + "Auth is not explicit in capture state: Step 2 Configure — strategy: Workload Identity Federation" + ] + }, + { + "slug": "spotify", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth configure — only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 5 + } + ], + "ambiguities": [] + }, + { + "slug": "stripe", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "supabase", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "telegram-bot", + "stateCount": 1, + "states": [ + { + "label": "initial (API Key configure — only state)", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [] + }, + { + "slug": "ticket-tailor", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "ticktick", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 5, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 7, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "todoist", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "typeform", + "stateCount": 3, + "states": [ + { + "label": "initial (Select Connection Type)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 0, + "linkCount": 0 + }, + { + "label": "OAuth 2.0 selected", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 4 + }, + { + "label": "API Key selected", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 6, + "linkCount": 4 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (Select Connection Type)" + ] + }, + { + "slug": "vercel", + "stateCount": 2, + "states": [ + { + "label": "initial — Step 2 Configure (MCP, auto-discovered details)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 2 + }, + { + "label": "Step 2 Configure — after clicking \"enter details manually\"", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 2 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Configure (MCP, auto-discovered details)" + ] + }, + { + "slug": "webflow", + "stateCount": 5, + "states": [ + { + "label": "initial — Step 2 Details (radio MCP | API, MCP default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 3, + "linkCount": 0 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Managed (default)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 7, + "linkCount": 2 + }, + { + "label": "Step 3 Configure — path: MCP — tab: Your own credentials", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 9, + "linkCount": 4 + }, + { + "label": "Step 3 Configure — path: API — auth: OAuth 2.0 (default; captured in a fresh dialog, name red-anchor)", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 7, + "linkCount": 5 + }, + { + "label": "Step 3 Configure — path: API — auth: API Key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "fieldCount": 10, + "linkCount": 3 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Details (radio MCP | API, MCP default)", + "Auth is not explicit in capture state: Step 3 Configure — path: MCP — tab: Managed (default)" + ] + }, + { + "slug": "wix", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "workos", + "stateCount": 1, + "states": [ + { + "label": "initial — Step 2 Configure (single state)", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [], + "fieldCount": 1, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial — Step 2 Configure (single state)" + ] + }, + { + "slug": "xero", + "stateCount": 1, + "states": [ + { + "label": "initial (opens directly in manual OAuth-app mode; no managed option)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "platform_shared", + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [] + }, + { + "slug": "zapier", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "zeplin", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth configure — only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 5 + } + ], + "ambiguities": [] + }, + { + "slug": "zomato", + "stateCount": 2, + "states": [ + { + "label": "initial (managed setup, step 2 \"Configure\")", + "transport": "mcp_remote", + "auth": null, + "ownershipModes": [ + "platform_shared" + ], + "fieldCount": 6, + "linkCount": 0 + }, + { + "label": "manual (after clicking \"enter details manually\")", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "fieldCount": 8, + "linkCount": 0 + } + ], + "ambiguities": [ + "Auth is not explicit in capture state: initial (managed setup, step 2 \"Configure\")" + ] + }, + { + "slug": "zoom", + "stateCount": 1, + "states": [ + { + "label": "initial (OAuth configure — only state)", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [], + "fieldCount": 6, + "linkCount": 5 + } + ], + "ambiguities": [] + } + ] +} diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts new file mode 100644 index 0000000000..8e93700c44 --- /dev/null +++ b/packages/shared/src/app-definitions.test.ts @@ -0,0 +1,12 @@ +import { describe,expect,it } from "vitest"; +import { APP_DEFINITIONS } from "./app-definitions.generated.js"; +import { appDefinitionsSchema } from "./validators/app-definition.js"; +describe("AppDefinition catalog",()=>{ + it("validates all Wave 1 definitions",()=>expect(()=>appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow()); + it("contains twelve reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"])); + it.each([ + ["notion",["read_content","update_content"]], + ["linear",["read","write"]], + ])("preserves required OAuth scopes for %s",(slug,scopes)=>expect(APP_DEFINITIONS.find((app)=>app.slug===slug)?.methods[0]?.defaults?.scopesHint).toEqual(scopes)); + it("enforces method and field invariants",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods){if(method.auth==="api_key")expect(method.keyPlacement).toBeTruthy();if(method.auth==="oauth")expect(method.ownershipModes.length).toBeGreaterThan(0);for(const field of method.credentialFields??[])if(field.required&&field.type!=="checkbox")expect(field.placeholder).toBeTruthy()}}); +}); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts new file mode 100644 index 0000000000..ba38d87e7f --- /dev/null +++ b/packages/shared/src/app-definitions.ts @@ -0,0 +1,67 @@ +import { APP_DEFINITIONS } from "./app-definitions.generated.js"; +import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-definition.js"; +import type { ToolConnectionOwnership } from "./types/tool-access.js"; + +const CONNECTABLE_APP_SLUGS = new Set([ + "zapier", + "github", + "slack", + "notion", + "linear", + "google-sheets", + "context7", +]); + +export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) => + CONNECTABLE_APP_SLUGS.has(app.slug) +); + +export const DEFAULT_OWNERSHIP_AVAILABILITY: Record = { + platform_shared: false, + platform_provisioned: false, + customer: true, + dcr: true, +}; + +export function getConnectableAppDefinition(slug: string): AppDefinition | null { + return CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug) ?? null; +} + +function wildcardPatternToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`, "i"); +} + +export function getAppDefinitionForUrl( + link: string, + definitions: readonly AppDefinition[] = CONNECTABLE_APP_DEFINITIONS, +): AppDefinition | null { + let normalized: string; + try { + normalized = new URL(link.trim()).toString(); + } catch { + return null; + } + return definitions.find((app) => + app.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized)) + ) ?? null; +} + +export function getAvailableConnectionMethod(app: AppDefinition): ConnectionMethodDef | null { + const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY; + return app.methods.find((method) => + method.ownershipModes.some((ownership) => availability[ownership] !== false) + ) ?? null; +} + +export function credentialConfigPath(field: FieldDef): string { + return `credentials.${field.key}`; +} + +export function recommendedDefaultsForApp(app: AppDefinition): Record { + const method = getAvailableConnectionMethod(app); + return { + access: "all_agents", + askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"], + }; +} diff --git a/packages/shared/src/app-definitions/anthropic.json b/packages/shared/src/app-definitions/anthropic.json new file mode 100644 index 0000000000..4fbf1dbe5d --- /dev/null +++ b/packages/shared/src/app-definitions/anthropic.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "slug": "anthropic", + "name": "Anthropic", + "description": "Use Anthropic APIs with a restricted key.", + "categories": [ + "ai" + ], + "featured": false, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=anthropic.com&sz=128" + }, + "urlPatterns": [ + "https://api.anthropic.com/*" + ], + "methods": [ + { + "key": "api-key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use credentials from your provider account.", + "defaults": { + "serviceHost": "api.anthropic.com" + }, + "guidanceMd": "Create a key in the Anthropic Console and rotate it if it has been exposed.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "apiKey", + "label": "API key", + "type": "password", + "required": true, + "placeholder": "sk-ant-api03-...", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "x-api-key" + } + } + ] +} diff --git a/packages/shared/src/app-definitions/api-key-generic.json b/packages/shared/src/app-definitions/api-key-generic.json new file mode 100644 index 0000000000..6ef035757d --- /dev/null +++ b/packages/shared/src/app-definitions/api-key-generic.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "slug": "api-key-generic", + "name": "API key app", + "description": "Connect an API using a key from your provider.", + "categories": [ + "other" + ], + "featured": false, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=openapis.org&sz=128" + }, + "urlPatterns": [], + "methods": [ + { + "key": "api-key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use credentials from your provider account.", + "defaults": {}, + "guidanceMd": "Create a restricted API key and paste it here.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "apiKey", + "label": "API key", + "type": "password", + "required": true, + "placeholder": "Paste the API key", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + } + } + ] +} diff --git a/packages/shared/src/app-definitions/context7.json b/packages/shared/src/app-definitions/context7.json new file mode 100644 index 0000000000..46a3fcfac0 --- /dev/null +++ b/packages/shared/src/app-definitions/context7.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "slug": "context7", + "name": "Context7", + "description": "Look up current documentation for software libraries.", + "categories": [ + "developer" + ], + "featured": false, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=context7.com&sz=128" + }, + "urlPatterns": [ + "https://mcp.context7.com/*" + ], + "methods": [ + { + "key": "mcp", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.context7.com/mcp" + }, + "guidanceMd": "Connect Context7 to give agents current library documentation.", + "riskTier": "S1" + } + ] +} diff --git a/packages/shared/src/app-definitions/github.json b/packages/shared/src/app-definitions/github.json new file mode 100644 index 0000000000..9194f39720 --- /dev/null +++ b/packages/shared/src/app-definitions/github.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "slug": "github", + "name": "GitHub", + "description": "Read code and pull requests, and coordinate repository work.", + "categories": [ + "developer" + ], + "featured": true, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=github.com&sz=128" + }, + "urlPatterns": [ + "https://api.githubcopilot.com/mcp/*" + ], + "methods": [ + { + "key": "mcp-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://api.githubcopilot.com/mcp/" + }, + "guidanceMd": "Create a fine-grained token limited to the repositories agents should use.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "authorization", + "label": "GitHub token", + "type": "password", + "required": true, + "placeholder": "github_pat_...", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "requiredResourceFilters": [ + "organization", + "repository" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/google-sheets.json b/packages/shared/src/app-definitions/google-sheets.json new file mode 100644 index 0000000000..c46562e634 --- /dev/null +++ b/packages/shared/src/app-definitions/google-sheets.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "slug": "google-sheets", + "name": "Google Sheets", + "description": "Read and update selected spreadsheets.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=sheets.google.com&sz=128" + }, + "urlPatterns": [ + "https://docs.google.com/spreadsheets/*", + "https://sheets.google.com/*" + ], + "methods": [ + { + "key": "local", + "transport": "local_stdio", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use credentials from your provider account.", + "defaults": { + "templateKey": "paperclip.google-sheets" + }, + "guidanceMd": "Share each spreadsheet with the Paperclip robot email, then paste the sheet links.", + "riskTier": "S3", + "requiredResourceFilters": [ + "spreadsheet" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/linear.json b/packages/shared/src/app-definitions/linear.json new file mode 100644 index 0000000000..2ee840e37f --- /dev/null +++ b/packages/shared/src/app-definitions/linear.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "slug": "linear", + "name": "Linear", + "description": "Create, update, and read Linear issues.", + "categories": [ + "productivity" + ], + "featured": true, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=linear.app&sz=128" + }, + "urlPatterns": [ + "https://mcp.linear.app/*" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer", + "dcr" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.linear.app/mcp", + "authorizationEndpoint": "https://linear.app/oauth/authorize", + "tokenEndpoint": "https://api.linear.app/oauth/token", + "scopesHint": [ + "read", + "write" + ] + }, + "guidanceMd": "Register a Linear OAuth app and add Paperclip's redirect URI before connecting.", + "riskTier": "S2", + "requiredResourceFilters": [ + "workspace", + "team", + "project" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/notion.json b/packages/shared/src/app-definitions/notion.json new file mode 100644 index 0000000000..8e95443e8d --- /dev/null +++ b/packages/shared/src/app-definitions/notion.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "slug": "notion", + "name": "Notion", + "description": "Read and update pages in your Notion workspace.", + "categories": [ + "content" + ], + "featured": true, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=notion.so&sz=128" + }, + "urlPatterns": [ + "https://mcp.notion.com/*" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer", + "dcr" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.notion.com/mcp", + "authorizationEndpoint": "https://api.notion.com/v1/oauth/authorize", + "tokenEndpoint": "https://api.notion.com/v1/oauth/token", + "scopesHint": [ + "read_content", + "update_content" + ] + }, + "guidanceMd": "Connect Notion for workspace content. Share only the pages and databases agents should use.", + "riskTier": "S3", + "requiredResourceFilters": [ + "workspace", + "page", + "database" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/oauth-generic.json b/packages/shared/src/app-definitions/oauth-generic.json new file mode 100644 index 0000000000..b344ba13a4 --- /dev/null +++ b/packages/shared/src/app-definitions/oauth-generic.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "slug": "oauth-generic", + "name": "OAuth app", + "description": "Connect a provider using your own OAuth client.", + "categories": [ + "other" + ], + "featured": false, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=oauth.net&sz=128" + }, + "urlPatterns": [], + "methods": [ + { + "key": "oauth", + "transport": "rest_api", + "auth": "oauth", + "ownershipModes": [ + "customer", + "dcr" + ], + "whenToUse": "Use credentials from your provider account.", + "defaults": {}, + "guidanceMd": "Register an OAuth client with the provider and add Paperclip's redirect URI.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "clientId", + "label": "Client ID", + "type": "text", + "required": true, + "placeholder": "Paste the client ID", + "secret": false + }, + { + "key": "clientSecret", + "label": "Client secret", + "type": "password", + "required": true, + "placeholder": "Paste the client secret", + "secret": true + } + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/sentry.json b/packages/shared/src/app-definitions/sentry.json new file mode 100644 index 0000000000..3b69843387 --- /dev/null +++ b/packages/shared/src/app-definitions/sentry.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "slug": "sentry", + "name": "Sentry", + "description": "Investigate errors, releases, and production issues.", + "categories": [ + "developer" + ], + "featured": false, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=sentry.io&sz=128" + }, + "urlPatterns": [ + "https://mcp.sentry.dev/*" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer", + "dcr" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.sentry.dev/mcp", + "discoveryUrl": "https://sentry.io/.well-known/oauth-authorization-server" + }, + "guidanceMd": "Connect the Sentry organization and projects agents need for incident work.", + "riskTier": "S2", + "requiredResourceFilters": [ + "organization", + "project", + "environment" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/slack.json b/packages/shared/src/app-definitions/slack.json new file mode 100644 index 0000000000..463994178a --- /dev/null +++ b/packages/shared/src/app-definitions/slack.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "slug": "slack", + "name": "Slack", + "description": "Search channels and coordinate team communication.", + "categories": [ + "communication" + ], + "featured": true, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=slack.com&sz=128" + }, + "urlPatterns": [ + "https://mcp.slack.com/*" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer", + "dcr" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.slack.com/mcp", + "authorizationEndpoint": "https://slack.com/oauth/v2/authorize", + "tokenEndpoint": "https://slack.com/api/oauth.v2.access", + "scopesHint": [ + "channels:read", + "chat:write", + "search:read" + ] + }, + "guidanceMd": "Connect a Slack workspace and limit access to the channels agents need.", + "riskTier": "S3", + "requiredResourceFilters": [ + "workspace", + "channel" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/vercel.json b/packages/shared/src/app-definitions/vercel.json new file mode 100644 index 0000000000..fc720e71f2 --- /dev/null +++ b/packages/shared/src/app-definitions/vercel.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "slug": "vercel", + "name": "Vercel", + "description": "Inspect projects, deployments, and runtime logs.", + "categories": [ + "developer" + ], + "featured": false, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=vercel.com&sz=128" + }, + "urlPatterns": [ + "https://mcp.vercel.com/*" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer", + "dcr" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.vercel.com/mcp" + }, + "guidanceMd": "Connect the Vercel team and projects agents should operate.", + "riskTier": "S3", + "requiredResourceFilters": [ + "team", + "project", + "environment" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/zapier.json b/packages/shared/src/app-definitions/zapier.json new file mode 100644 index 0000000000..016771b654 --- /dev/null +++ b/packages/shared/src/app-definitions/zapier.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "slug": "zapier", + "name": "Zapier", + "description": "Reach thousands of apps through your Zapier account.", + "categories": [ + "productivity" + ], + "featured": true, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=zapier.com&sz=128" + }, + "urlPatterns": [ + "https://mcp.zapier.com/*" + ], + "methods": [ + { + "key": "mcp-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.zapier.com/api/mcp" + }, + "guidanceMd": "Create a Zapier MCP connection, then paste its token here.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "authorization", + "label": "Zapier MCP token", + "type": "password", + "required": true, + "placeholder": "Paste your Zapier token", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + } + } + ] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5ca9d606b6..488d2c4ef6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -143,15 +143,16 @@ export { type SourceTrustMetadata, } from "./trust-policy.js"; export { - TOOL_APP_GALLERY, - getToolAppGalleryEntry, - getToolAppGalleryEntryForUrl, - type AppGalleryAuthKind, - type AppGalleryCredentialField, - type AppGalleryEntry, - type AppGalleryKey, - type AppGalleryTransportTemplate, -} from "./tool-app-gallery.js"; + CONNECTABLE_APP_DEFINITIONS, + DEFAULT_OWNERSHIP_AVAILABILITY, + credentialConfigPath, + getAppDefinitionForUrl, + getAvailableConnectionMethod, + getConnectableAppDefinition, + recommendedDefaultsForApp, +} from "./app-definitions.js"; +export { APP_DEFINITIONS } from "./app-definitions.generated.js"; +export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js"; export { humanizeConnectionDisplayName, connectionDisplaySecondaryHint, @@ -1257,6 +1258,9 @@ export type { PluginJobRecord, PluginJobRunRecord, PluginWebhookDeliveryRecord, + AppDefinition, + ConnectionMethodDef, + FieldDef, QuotaWindow, ProviderQuotaResult, } from "./types/index.js"; diff --git a/packages/shared/src/tool-app-gallery.test.ts b/packages/shared/src/tool-app-gallery.test.ts deleted file mode 100644 index 2131806653..0000000000 --- a/packages/shared/src/tool-app-gallery.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getToolAppGalleryEntryForUrl, - TOOL_APP_GALLERY, -} from "./tool-app-gallery.js"; - -describe("tool app gallery URL matching", () => { - it("matches pasted links against gallery URL patterns", () => { - expect(getToolAppGalleryEntryForUrl("https://mcp.zapier.com/api/mcp")?.key).toBe("zapier"); - expect(getToolAppGalleryEntryForUrl("https://api.githubcopilot.com/mcp/")?.key).toBe("github"); - expect(getToolAppGalleryEntryForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.key).toBe("google-sheets"); - }); - - it("returns null for invalid or unknown links", () => { - expect(getToolAppGalleryEntryForUrl("not a url")).toBeNull(); - expect(getToolAppGalleryEntryForUrl("https://example.com/mcp")).toBeNull(); - expect(getToolAppGalleryEntryForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull(); - }); - - it("does not list Google Drive until its OAuth client flow is supported", () => { - expect(TOOL_APP_GALLERY.map((entry) => entry.key)).not.toContain("google-drive"); - expect(getToolAppGalleryEntryForUrl("https://mcp.google.com/drive")).toBeNull(); - }); - - it("keeps every gallery entry reachable through at least one pattern", () => { - for (const entry of TOOL_APP_GALLERY) { - const example = entry.urlPatterns[0]?.replace("*", "example"); - expect(example, `${entry.key} has a pattern`).toBeTruthy(); - expect(getToolAppGalleryEntryForUrl(example!)?.key).toBe(entry.key); - } - }); -}); diff --git a/packages/shared/src/tool-app-gallery.ts b/packages/shared/src/tool-app-gallery.ts deleted file mode 100644 index bb54fcb360..0000000000 --- a/packages/shared/src/tool-app-gallery.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { ToolConnectionTransport } from "./types/tool-access.js"; - -export type AppGalleryAuthKind = "oauth" | "api_key" | "none"; - -export interface AppGalleryCredentialField { - label: string; - configPath: string; - helpUrl: string; - required?: boolean; - placement?: "header" | "env"; - key?: string; - prefix?: string | null; -} - -export type AppGalleryTransportTemplate = - | { - transport: Extract; - url: string; - } - | { - transport: Extract; - templateKey: string; - }; - -export interface AppGalleryEntry { - key: string; - name: string; - logoUrl: string; - tagline: string; - description?: string; - authKind: AppGalleryAuthKind; - transportTemplate: AppGalleryTransportTemplate; - credentialFields: AppGalleryCredentialField[]; - recommendedDefaults: Record; - urlPatterns: string[]; - availability?: { - available: boolean; - reason?: string | null; - robotEmail?: string | null; - }; - oauth?: { - provider: string; - scopes: string[]; - tokenUrl?: string | null; - metadataUrl?: string | null; - authorizationUrl?: string | null; - }; -} - -const favicon = (domain: string) => `https://www.google.com/s2/favicons?domain=${domain}&sz=64`; - -export const TOOL_APP_GALLERY = [ - { - key: "zapier", - name: "Zapier", - logoUrl: favicon("zapier.com"), - tagline: "Connect Zapier-hosted actions to your Paperclip agents.", - description: "Let agents use Zapier automations across the apps your business already runs. Good for handoffs, lightweight operations, and cross-app updates that should stay visible in Paperclip.", - authKind: "api_key", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.zapier.com/api/mcp", - }, - credentialFields: [ - { - label: "Zapier MCP token", - configPath: "credentials.authorization", - helpUrl: "https://zapier.com/app/settings/authorizations", - required: true, - placement: "header", - key: "Authorization", - prefix: "Bearer ", - }, - ], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.zapier.com/*"], - }, - { - key: "github", - name: "GitHub", - logoUrl: favicon("github.com"), - tagline: "Read and manage GitHub issues and pull requests.", - description: "Give agents a governed way to inspect repositories, issues, and pull requests. Useful when engineering work needs GitHub context or small updates without leaving Paperclip.", - authKind: "api_key", - transportTemplate: { - transport: "mcp_remote", - url: "https://api.githubcopilot.com/mcp/", - }, - credentialFields: [ - { - label: "GitHub token", - configPath: "credentials.authorization", - helpUrl: "https://github.com/settings/tokens", - required: true, - placement: "header", - key: "Authorization", - prefix: "Bearer ", - }, - ], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://api.githubcopilot.com/mcp/*"], - }, - { - key: "slack", - name: "Slack", - logoUrl: favicon("slack.com"), - tagline: "Search channels and coordinate Slack actions.", - description: "Let agents search workspace conversations and coordinate in Slack when work needs team context. Message-sending actions can still ask a human first.", - authKind: "oauth", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.slack.com/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.slack.com/*"], - oauth: { - provider: "slack", - scopes: ["channels:read", "chat:write", "search:read"], - authorizationUrl: "https://slack.com/oauth/v2/authorize", - tokenUrl: "https://slack.com/api/oauth.v2.access", - }, - }, - { - key: "notion", - name: "Notion", - logoUrl: favicon("notion.so"), - tagline: "Search and update Notion workspace content.", - description: "Connect Notion so agents can find docs, read project notes, and update workspace pages. Use it for company memory that lives outside Paperclip.", - authKind: "oauth", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.notion.com/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.notion.com/*"], - oauth: { - provider: "notion", - scopes: ["read_content", "update_content"], - authorizationUrl: "https://api.notion.com/v1/oauth/authorize", - tokenUrl: "https://api.notion.com/v1/oauth/token", - }, - }, - { - key: "linear", - name: "Linear", - logoUrl: favicon("linear.app"), - tagline: "Read and update Linear issues from agent workflows.", - description: "Let agents look up Linear work and make issue updates when their Paperclip tasks depend on your existing product queue.", - authKind: "oauth", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.linear.app/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.linear.app/*"], - oauth: { - provider: "linear", - scopes: ["read", "write"], - authorizationUrl: "https://linear.app/oauth/authorize", - tokenUrl: "https://api.linear.app/oauth/token", - }, - }, - { - key: "google-sheets", - name: "Google Sheets", - logoUrl: favicon("sheets.google.com"), - tagline: "Read and update selected spreadsheets.", - description: "Let agents read and update only the spreadsheets you choose. Share each sheet with the robot email, then paste the sheet links here.", - authKind: "none", - transportTemplate: { - transport: "local_stdio", - templateKey: "paperclip.google-sheets", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://docs.google.com/spreadsheets/*", "https://sheets.google.com/*"], - }, - { - key: "context7", - name: "Context7", - logoUrl: favicon("context7.com"), - tagline: "Fetch up-to-date library documentation with Context7.", - description: "Let agents pull current library documentation while they work. It is a low-risk reference app for coding and research tasks.", - authKind: "none", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.context7.com/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: [], - }, - urlPatterns: ["https://mcp.context7.com/*"], - }, -] satisfies AppGalleryEntry[]; - -export type AppGalleryKey = (typeof TOOL_APP_GALLERY)[number]["key"]; - -export function getToolAppGalleryEntry(key: string): AppGalleryEntry | null { - return TOOL_APP_GALLERY.find((entry) => entry.key === key) ?? null; -} - -function wildcardPatternToRegExp(pattern: string): RegExp { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); - return new RegExp(`^${escaped}$`, "i"); -} - -export function getToolAppGalleryEntryForUrl( - link: string, - entries: readonly AppGalleryEntry[] = TOOL_APP_GALLERY, -): AppGalleryEntry | null { - let normalized: string; - try { - normalized = new URL(link.trim()).toString(); - } catch { - return null; - } - return entries.find((entry) => - entry.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized)) - ) ?? null; -} diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts new file mode 100644 index 0000000000..abbc3abfc4 --- /dev/null +++ b/packages/shared/src/types/app-definition.ts @@ -0,0 +1,5 @@ +import type { ToolConnectionOwnership, ToolConnectionTransport } from "./tool-access.js"; +export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other"; +export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}> } +export interface ConnectionMethodDef { key:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";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; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index d891d65779..982f68c31d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -874,3 +874,4 @@ export type { PluginDatabaseNamespaceMode, PluginDatabaseNamespaceStatus, } from "./plugin.js"; +export * from "./app-definition.js"; diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts new file mode 100644 index 0000000000..4aa559b2ae --- /dev/null +++ b/packages/shared/src/validators/app-definition.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; +import { toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js"; +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(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]})}); +export const connectionMethodDefSchema=z.object({key:z.string().min(1),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().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()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).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(),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.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]})}); +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:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().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/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 24df77db43..b6586dd05b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -870,3 +870,4 @@ export { type RevokeToolTrustRule, } from "./tool-access.js"; export * from "./skill-policy.js"; +export * from "./app-definition.js"; diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs new file mode 100644 index 0000000000..498a37594f --- /dev/null +++ b/scripts/ingest-app-definitions.mjs @@ -0,0 +1,64 @@ +import fs from "node:fs"; import path from "node:path"; +const root=process.cwd(); const corpus=process.env.PAPERCLIP_CONTENT_TEMPLATES??path.resolve(root,"../../../paperclip-content/research/connections/vercel/templates"); +const out=path.join(root,"packages/shared/src/app-definitions"); const favicon=d=>`https://www.google.com/s2/favicons?domain=${d}&sz=128`; +const field=(key,label,placeholder)=>({key,label,type:"password",required:true,placeholder,secret:true}); +const method=(key,transport,auth,defaults,riskTier,guidanceMd,extra={})=>({key,transport,auth,ownershipModes:auth==="oauth"?["customer","dcr"]:["customer"],whenToUse:transport==="mcp_remote"?"Use the provider-hosted connection for the quickest setup.":"Use credentials from your provider account.",defaults,guidanceMd,riskTier,...extra}); +const apps=[ +["zapier","Zapier","Reach thousands of apps through your Zapier account.","productivity","zapier.com",["https://mcp.zapier.com/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://mcp.zapier.com/api/mcp"},"S3","Create a Zapier MCP connection, then paste its token here.",{credentialFields:[field("authorization","Zapier MCP token","Paste your Zapier token")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})], +["github","GitHub","Read code and pull requests, and coordinate repository work.","developer","github.com",["https://api.githubcopilot.com/mcp/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://api.githubcopilot.com/mcp/"},"S3","Create a fine-grained token limited to the repositories agents should use.",{credentialFields:[field("authorization","GitHub token","github_pat_...")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "},requiredResourceFilters:["organization","repository"]})], +["slack","Slack","Search channels and coordinate team communication.","communication","slack.com",["https://mcp.slack.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.slack.com/mcp",authorizationEndpoint:"https://slack.com/oauth/v2/authorize",tokenEndpoint:"https://slack.com/api/oauth.v2.access",scopesHint:["channels:read","chat:write","search:read"]},"S3","Connect a Slack workspace and limit access to the channels agents need.",{requiredResourceFilters:["workspace","channel"]})], +["notion","Notion","Read and update pages in your Notion workspace.","content","notion.so",["https://mcp.notion.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.notion.com/mcp",authorizationEndpoint:"https://api.notion.com/v1/oauth/authorize",tokenEndpoint:"https://api.notion.com/v1/oauth/token",scopesHint:["read_content","update_content"]},"S3","Connect Notion for workspace content. Share only the pages and databases agents should use.",{requiredResourceFilters:["workspace","page","database"]})], +["linear","Linear","Create, update, and read Linear issues.","productivity","linear.app",["https://mcp.linear.app/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.linear.app/mcp",authorizationEndpoint:"https://linear.app/oauth/authorize",tokenEndpoint:"https://api.linear.app/oauth/token",scopesHint:["read","write"]},"S2","Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",{requiredResourceFilters:["workspace","team","project"]})], +["google-sheets","Google Sheets","Read and update selected spreadsheets.","data","sheets.google.com",["https://docs.google.com/spreadsheets/*","https://sheets.google.com/*"],method("local","local_stdio","none",{templateKey:"paperclip.google-sheets"},"S3","Share each spreadsheet with the Paperclip robot email, then paste the sheet links.",{requiredResourceFilters:["spreadsheet"]})], +["context7","Context7","Look up current documentation for software libraries.","developer","context7.com",["https://mcp.context7.com/*"],method("mcp","mcp_remote","none",{serverUrl:"https://mcp.context7.com/mcp"},"S1","Connect Context7 to give agents current library documentation.")], +["oauth-generic","OAuth app","Connect a provider using your own OAuth client.","other","oauth.net",[],method("oauth","rest_api","oauth",{},"S3","Register an OAuth client with the provider and add Paperclip's redirect URI.",{credentialFields:[{...field("clientId","Client ID","Paste the client ID"),type:"text",secret:false},field("clientSecret","Client secret","Paste the client secret")]})], +["api-key-generic","API key app","Connect an API using a key from your provider.","other","openapis.org",[],method("api-key","rest_api","api_key",{},"S3","Create a restricted API key and paste it here.",{credentialFields:[field("apiKey","API key","Paste the API key")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})], +["sentry","Sentry","Investigate errors, releases, and production issues.","developer","sentry.io",["https://mcp.sentry.dev/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.sentry.dev/mcp",discoveryUrl:"https://sentry.io/.well-known/oauth-authorization-server"},"S2","Connect the Sentry organization and projects agents need for incident work.",{requiredResourceFilters:["organization","project","environment"]})], +["vercel","Vercel","Inspect projects, deployments, and runtime logs.","developer","vercel.com",["https://mcp.vercel.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.vercel.com/mcp"},"S3","Connect the Vercel team and projects agents should operate.",{requiredResourceFilters:["team","project","environment"]})], +["anthropic","Anthropic","Use Anthropic APIs with a restricted key.","ai","anthropic.com",["https://api.anthropic.com/*"],method("api-key","rest_api","api_key",{serviceHost:"api.anthropic.com"},"S3","Create a key in the Anthropic Console and rotate it if it has been exposed.",{credentialFields:[field("apiKey","API key","sk-ant-api03-...")],keyPlacement:{location:"header",name:"x-api-key"}})], +].map(([slug,name,description,category,domain,urlPatterns,m])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:[m]})); +const parseTableRow=(line)=>line.slice(1,-1).split("|").map((cell)=>cell.trim()); +const parseCapture=(fileName)=>{ + const markdown=fs.readFileSync(path.join(corpus,fileName),"utf8"); + const stateMatches=[...markdown.matchAll(/^## State: (.+)$/gm)]; + if(stateMatches.length===0) throw new Error(`${fileName}: no captured states`); + return stateMatches.map((match,index)=>{ + const body=markdown.slice(match.index+match[0].length,stateMatches[index+1]?.index??markdown.length); + const inputsBlock=body.match(/### Inputs\n([\s\S]*?)(?=\n### |$)/)?.[1]??""; + const inputRows=inputsBlock.split("\n").filter((line)=>line.startsWith("|")).slice(2).map(parseTableRow); + const fields=inputRows.map(([label,tagType,required,placeholder,prefilledValue,checked])=>({label,tagType,required:required.toLowerCase()==="yes",placeholder:placeholder||null,prefilledValue:prefilledValue||null,checked:checked.toLowerCase()==="true"})); + const linksBlock=body.match(/### Links\n([\s\S]*?)(?=\n## |$)/)?.[1]??""; + const links=linksBlock.split("\n").map((line)=>line.match(/^(.+?) → (https?:\/\/\S+)$/)).filter(Boolean).map((link)=>({label:link[1].trim(),href:link[2]})); + return {label:match[1].trim(),fields,links}; + }); +}; +const inferState=(slug,state)=>{ + const label=state.label.toLowerCase(); + const fieldText=state.fields.map((field)=>field.label.toLowerCase()).join(" "); + const transport=slug==="oauth-generic"||slug==="api-key-generic"||label.includes("path: api")||label.includes("api key form")?"rest_api":"mcp_remote"; + const auth=slug==="oauth-generic"||label.includes("oauth")||fieldText.includes("client id")?"oauth":slug==="api-key-generic"||label.includes("api key")||fieldText.includes("api key")?"api_key":null; + const ownershipModes=[]; + if(label.includes("managed")) ownershipModes.push("platform_shared"); + if(label.includes("your own credentials")||label.includes("manual")||label.includes("api key")) ownershipModes.push("customer"); + if(slug==="oauth-generic"&&!label.includes("manually")) ownershipModes.push("dcr"); + return {label:state.label,transport,auth,ownershipModes:[...new Set(ownershipModes)],fieldCount:state.fields.length,linkCount:state.links.length}; +}; +const validateApp=(app)=>{ + if(app.schemaVersion!==1||!app.slug||!app.name||!Array.isArray(app.methods)||app.methods.length===0) throw new Error(`${app.slug||"unknown"}: invalid AppDefinition`); + for(const connectionMethod of app.methods){ + if(connectionMethod.auth==="api_key"&&!connectionMethod.keyPlacement) throw new Error(`${app.slug}/${connectionMethod.key}: api_key requires keyPlacement`); + if(connectionMethod.auth==="oauth"&&connectionMethod.ownershipModes.length===0) throw new Error(`${app.slug}/${connectionMethod.key}: oauth requires ownershipModes`); + for(const connectionField of [...connectionMethod.tenantFields??[],...connectionMethod.extensionFields??[],...connectionMethod.credentialFields??[]]) if(connectionField.required&&connectionField.type!=="checkbox"&&!connectionField.placeholder) throw new Error(`${app.slug}/${connectionMethod.key}/${connectionField.key}: required field needs placeholder`); + } +}; +const captureFiles=fs.readdirSync(corpus).filter((fileName)=>fileName.endsWith(".md")&&fileName!=="INDEX.md").sort(); +if(captureFiles.length!==99) throw new Error(`Expected 99 captures, found ${captureFiles.length}`); +const parsedCaptures=Object.fromEntries(captureFiles.map((fileName)=>[path.basename(fileName,".md"),parseCapture(fileName)])); +const reviewReport={schemaVersion:1,corpusSize:captureFiles.length,providers:captureFiles.map((fileName)=>{const slug=path.basename(fileName,".md");const states=parsedCaptures[slug].map((state)=>inferState(slug,state));return {slug,stateCount:states.length,states,ambiguities:states.filter((state)=>!state.auth).map((state)=>`Auth is not explicit in capture state: ${state.label}`)};})}; +for(const app of apps){validateApp(app);if(parsedCaptures[app.slug]&&parsedCaptures[app.slug].length===0) throw new Error(`${app.slug}: capture has no states`);} +fs.mkdirSync(out,{recursive:true}); for(const app of apps) fs.writeFileSync(path.join(out,`${app.slug}.json`),JSON.stringify(app,null,2)+"\n"); +fs.writeFileSync(path.join(root,"packages/shared/src/app-definitions.ingestion-report.json"),JSON.stringify(reviewReport,null,2)+"\n"); +const imports=apps.map((a,i)=>`import a${i} from "./app-definitions/${a.slug}.json" with { type: "json" };`).join("\n"); +fs.writeFileSync(path.join(root,"packages/shared/src/app-definitions.generated.ts"),`${imports}\nimport type { AppDefinition } from "./types/app-definition.js";\nexport const APP_DEFINITIONS=[${apps.map((_,i)=>`a${i}`).join(",")}] as AppDefinition[];\n`); +const ambiguityCount=reviewReport.providers.reduce((total,provider)=>total+provider.ambiguities.length,0); +console.log(`Parsed ${captureFiles.length} captures and ${reviewReport.providers.reduce((total,provider)=>total+provider.stateCount,0)} states; emitted ${apps.length} Wave 1 definitions and flagged ${ambiguityCount} states for review.`); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 10fbd18f8b..f5b3897936 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2285,7 +2285,7 @@ describeEmbeddedPostgres("tool access service", () => { const res = await request(app).get(`/api/companies/${company.id}/tools/gallery`); expect(res.status).toBe(200); - expect(res.body.apps.map((entry: { key: string }) => entry.key)).toEqual([ + expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual([ "zapier", "github", "slack", @@ -2294,23 +2294,36 @@ describeEmbeddedPostgres("tool access service", () => { "google-sheets", "context7", ]); - expect(res.body.apps.map((entry: { key: string }) => entry.key)).not.toContain("google-drive"); + expect(res.body.apps.map((app: { slug: string }) => app.slug)).not.toContain("google-drive"); expect(res.body.apps).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: "slack", - authKind: "oauth", - oauth: expect.objectContaining({ provider: "slack" }), + slug: "slack", + methods: expect.arrayContaining([ + expect.objectContaining({ + auth: "oauth", + defaults: expect.objectContaining({ authorizationEndpoint: "https://slack.com/oauth/v2/authorize" }), + }), + ]), + ownershipAvailability: expect.objectContaining({ + platform_shared: false, + platform_provisioned: false, + customer: true, + dcr: true, + }), }), expect.objectContaining({ - key: "zapier", - credentialFields: [ + slug: "zapier", + methods: expect.arrayContaining([ expect.objectContaining({ - configPath: "credentials.authorization", - placement: "header", - key: "Authorization", + credentialFields: [expect.objectContaining({ key: "authorization" })], + keyPlacement: expect.objectContaining({ location: "header", name: "Authorization" }), }), - ], + ]), + }), + expect.objectContaining({ + slug: "google-sheets", + availability: expect.objectContaining({ available: false }), }), ]), ); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 3aa75182c9..7915afd490 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -3,7 +3,8 @@ import type { Db } from "@paperclipai/db"; import { agents, companies } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { - TOOL_APP_GALLERY, + CONNECTABLE_APP_DEFINITIONS, + DEFAULT_OWNERSHIP_AVAILABILITY, TOOL_ACTION_REQUEST_STATUSES, type DeploymentExposure, type DeploymentMode, @@ -218,15 +219,16 @@ export function toolAccessRoutes( assertCompanyAccess(req, companyId); const googleSheetsAvailability = googleSheetsRobotEmailFromEnv(); res.json({ - apps: TOOL_APP_GALLERY.map((entry) => - entry.key === "google-sheets" + apps: CONNECTABLE_APP_DEFINITIONS.map((app) => + app.slug === "google-sheets" ? { - ...entry, + ...app, + ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY, availability: googleSheetsAvailability.available ? { available: true, robotEmail: googleSheetsAvailability.robotEmail } : { available: false, reason: googleSheetsAvailability.reason }, } - : entry, + : { ...app, ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY }, ), }); }); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 80589d2c0b..3ea6c9455e 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -33,6 +33,7 @@ import { toolRuntimeSlots, } from "@paperclipai/db"; import type { + AppDefinition, ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath, ConnectionTokenRequest, @@ -104,7 +105,7 @@ import type { UpdateToolProfileWithEntries, UnbindToolProfileBinding, } from "@paperclipai/shared"; -import { CLASS3_STATIC_LEASE_ALLOWLIST, getToolAppGalleryEntry, isToolConnectionAttentionHealth } from "@paperclipai/shared"; +import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js"; @@ -423,6 +424,25 @@ export function googleSheetsRobotEmailFromEnv( return { available: false, reason: "Google Sheets is not available on this instance yet." }; } +function connectionMethodFor(app: AppDefinition) { + const method = getAvailableConnectionMethod(app); + if (!method) throw unprocessable("This app does not have an available connection method"); + return method; +} + +function credentialFieldsFor(app: AppDefinition) { + const method = connectionMethodFor(app); + return (method.credentialFields ?? []).map((field) => ({ + label: field.label, + configPath: credentialConfigPath(field), + helpUrl: method.consoleLinks?.keys ?? method.consoleLinks?.docs ?? "", + required: field.required, + placement: method.keyPlacement?.location === "header" ? "header" as const : undefined, + key: method.keyPlacement?.name, + prefix: method.keyPlacement?.prefix, + })); +} + function googleSheetsAllowedSpreadsheetIds(configValues: Record | undefined): string[] { const raw = configValues?.allowedSpreadsheetIds; const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : []; @@ -3833,13 +3853,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return null; } - async function oauthProviderEndpoints(galleryEntry: NonNullable>): Promise { - const oauth = galleryEntry.oauth; - if (!oauth) throw unprocessable("This app does not support sign in"); - let authorizationUrl = oauth.authorizationUrl ?? null; - let tokenUrl = oauth.tokenUrl ?? null; - if ((!authorizationUrl || !tokenUrl) && oauth.metadataUrl) { - const response = await fetchRemoteHttpUrl(oauth.metadataUrl); + async function oauthProviderEndpoints(app: AppDefinition): Promise { + const method = connectionMethodFor(app); + if (method.auth !== "oauth") throw unprocessable("This app does not support sign in"); + let authorizationUrl = method.defaults?.authorizationEndpoint ?? null; + let tokenUrl = method.defaults?.tokenEndpoint ?? null; + const metadataUrl = method.defaults?.metadataUrl ?? null; + if ((!authorizationUrl || !tokenUrl) && metadataUrl) { + const response = await fetchRemoteHttpUrl(metadataUrl); if (!response.ok) throw new HttpError(502, "OAuth provider metadata could not be loaded", { code: "oauth_metadata_failed" }); const metadata = asRecord(await response.json() as unknown); authorizationUrl = authorizationUrl ?? (typeof metadata.authorization_endpoint === "string" ? metadata.authorization_endpoint : null); @@ -3848,7 +3869,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (!authorizationUrl || !tokenUrl) { throw unprocessable("OAuth provider endpoints are not configured for this app"); } - return { provider: oauth.provider, scopes: oauth.scopes, authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl: oauth.metadataUrl ?? null }; + return { provider: app.slug, scopes: method.defaults?.scopesHint ?? [], authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl }; } async function oauthEndpointsForConnection( @@ -3858,9 +3879,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} ): Promise { const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; - const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null; + const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; const endpoints = smokeLabEndpoints - ?? (galleryEntry?.authKind === "oauth" && galleryEntry.oauth + ?? (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth" ? await oauthProviderEndpoints(galleryEntry) : await discoverOAuthEndpoints(connection, challenge)); if (!endpoints) throw unprocessable("This app connection does not advertise OAuth sign in"); @@ -3871,8 +3892,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} async function oauthGalleryEntryForConnection(connection: typeof toolConnections.$inferSelect) { const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; if (!sourceTemplateKey) throw unprocessable("This app connection was not created from the app gallery"); - const galleryEntry = getToolAppGalleryEntry(sourceTemplateKey); - if (!galleryEntry || galleryEntry.authKind !== "oauth" || !galleryEntry.oauth) { + const galleryEntry = getConnectableAppDefinition(sourceTemplateKey); + if (!galleryEntry || connectionMethodFor(galleryEntry).auth !== "oauth") { throw unprocessable("This app connection does not use sign in"); } return galleryEntry; @@ -4087,7 +4108,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} input: ConnectToolApp, actor?: ActorInfo, ): Promise { - const galleryEntry = input.galleryKey ? getToolAppGalleryEntry(input.galleryKey) : null; + const galleryEntry = input.galleryKey ? getConnectableAppDefinition(input.galleryKey) : null; if (input.galleryKey && !galleryEntry) throw notFound("Tool app gallery entry not found"); let existingApplication: typeof toolApplications.$inferSelect | null = null; @@ -4101,18 +4122,15 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); - const transportTemplate = galleryEntry?.transportTemplate ?? { - transport: "mcp_remote" as const, - url: input.link ?? "", - }; - const transport = transportTemplate.transport; + const method = galleryEntry ? connectionMethodFor(galleryEntry) : null; + const transport = method?.transport ?? "mcp_remote"; const baseConfig = transport === "mcp_remote" - ? { url: transportTemplate.url } - : { templateId: transportTemplate.templateKey }; + ? { url: method?.defaults?.serverUrl ?? input.link ?? "" } + : { templateId: method?.defaults?.templateKey }; let config: Record = galleryEntry - ? { ...baseConfig, sourceTemplateKey: galleryEntry.key, quarantineNewEntries: true } + ? { ...baseConfig, sourceTemplateKey: galleryEntry.slug, quarantineNewEntries: true } : { ...baseConfig, quarantineNewEntries: true }; - if (galleryEntry?.key === GOOGLE_SHEETS_GALLERY_KEY) { + if (galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY) { const availability = googleSheetsRobotEmailFromEnv(); if (!availability.available) { throw unprocessable(availability.reason, { code: "google_sheets_unavailable" }); @@ -4142,7 +4160,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = null; try { - const credentialFields = galleryEntry?.credentialFields ?? linkCredentialFields(credentialValues); + const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : linkCredentialFields(credentialValues); for (const field of credentialFields) { const value = credentialValues[field.configPath]; if (!value && field.required !== false) { @@ -4188,12 +4206,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } else { [applicationRow] = await db.insert(toolApplications).values({ companyId, - applicationKey: `app-gallery:${galleryEntry?.key ?? "link"}:${randomUUID()}`, + applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, name, - description: galleryEntry?.tagline ?? `Connected app at ${input.link}`, + description: galleryEntry?.description ?? `Connected app at ${input.link}`, type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "draft", - metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.key, galleryKey: galleryEntry.key } : { source: "link" }, + metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" }, }).returning(); } @@ -4235,7 +4253,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} name, uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId), connectionKind: "managed", - authKind: galleryEntry?.authKind ?? "none", + authKind: galleryEntry ? connectionMethodFor(galleryEntry).auth : "none", transport, status: "draft", enabled: false, @@ -4250,14 +4268,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} await syncCredentialBindings(connectionRow); await ensureRuntimeSlot(connectionRow); - if (galleryEntry?.authKind === "oauth") { + if (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth") { return { connectionId: connectionRow.id, application: toApplication(applicationRow), connection: toConnection(connectionRow), catalog: [], actions: { readOnly: [], canMakeChanges: [] }, - suggestedDefaults: galleryEntry.recommendedDefaults, + suggestedDefaults: recommendedDefaultsForApp(galleryEntry), auth: { kind: "oauth", startUrl: null }, }; } @@ -4292,7 +4310,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: refresh.connection, catalog: refresh.catalog, actions: groupedActions(refresh.catalog), - suggestedDefaults: galleryEntry?.recommendedDefaults ?? { + suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"], }, @@ -4619,8 +4637,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; - const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null; - const credentialFields = galleryEntry?.credentialFields ?? [ + const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; + const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : [ { label: "App key", configPath: "credentials.authorization", @@ -4784,7 +4802,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; - const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null; + const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; const endpoints = await oauthEndpointsForConnection(connection, null, input.redirectUri); const client = oauthClientForConnection(connection, endpoints.provider); if (!client.clientId) throw unprocessable(`OAuth client id is not configured for ${endpoints.provider}`); @@ -4884,7 +4902,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: refresh.connection, catalog: refresh.catalog, actions: groupedActions(refresh.catalog), - suggestedDefaults: galleryEntry?.recommendedDefaults ?? { + suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"], }, diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 3f93ac8cd2..a49fe03b95 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -34,7 +34,7 @@ import type { ToolRiskLevel, UpdateToolPolicy, ReorderToolPolicies, - AppGalleryEntry, + AppDefinition, ToolAppsAttentionResponse, ToolConnectionActivityResponse, ToolConnectionTestAgentsResponse, @@ -70,7 +70,7 @@ export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary; export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] }; export type ToolPoliciesResponse = { policies: ToolPolicy[] }; export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] }; -export type ToolGalleryResponse = { apps: AppGalleryEntry[] }; +export type ToolGalleryResponse = { apps: AppDefinition[] }; export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] }; export type CreateGatewayTokenInput = Omit & { expiresAt?: string | Date | null; diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx index 36c58e6eda..adc50eef97 100644 --- a/ui/src/components/AppConnectionSidebar.tsx +++ b/ui/src/components/AppConnectionSidebar.tsx @@ -1,7 +1,7 @@ import { ChevronLeft } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { humanizeConnectionDisplayName } from "@paperclipai/shared"; -import type { AppGalleryEntry, ToolApplication, ToolConnection } from "@paperclipai/shared"; +import type { ToolApplication, ToolConnection } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { toolsApi } from "@/api/tools"; import { useCompany } from "@/context/CompanyContext"; @@ -15,6 +15,12 @@ import { type AppTabKey, } from "@/pages/apps/app-tabs"; import { AppLogo } from "@/pages/apps/AppLogo"; +import { + appDefinitionLogoUrl, + appDefinitionName, + appDefinitionSlug, + type AppGalleryDisplayEntry, +} from "@/pages/apps/app-definition-display"; import { SidebarNavItem } from "./SidebarNavItem"; type AppDetailSidebarProps = @@ -61,7 +67,11 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) { : []; const previousConnection = latestArchivedConnection(appConnections); const appName = connection ? humanizeConnectionDisplayName(connection) : application?.name ?? "App"; - const logoEntry = galleryEntryFor(galleryQuery.data?.apps ?? [], connection, application ?? undefined); + const logoEntry = galleryEntryFor( + (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], + connection, + application ?? undefined, + ); const reviewConnectionId = connection?.id ?? previousConnection?.id ?? null; const attentionItem = reviewConnectionId ? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId) @@ -83,7 +93,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) { All apps
- + {appName}
@@ -117,17 +127,19 @@ function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string { } function galleryEntryFor( - apps: AppGalleryEntry[], + apps: AppGalleryDisplayEntry[], connection: ToolConnection | undefined, application: ToolApplication | undefined, -): AppGalleryEntry | null { +): AppGalleryDisplayEntry | null { if (application?.applicationKey) { - const keyed = apps.find((app) => app.key === application.applicationKey); + const keyed = apps.find((app) => appDefinitionSlug(app) === application.applicationKey); if (keyed) return keyed; } const name = (connection?.name ?? application?.name)?.toLowerCase(); if (!name) return null; - return apps.find((app) => app.name.toLowerCase() === name) ?? apps.find((app) => app.key === name) ?? null; + return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ?? + apps.find((app) => appDefinitionSlug(app) === name) ?? + null; } function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null { diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 1f568cc450..010311c37a 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Pencil } from "lucide-react"; import type { - AppGalleryEntry, ToolConnection, ToolPolicy, ToolProfileWithDetails, @@ -28,6 +27,12 @@ import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { AppLogo } from "./AppLogo"; +import { + appDefinitionLogoUrl, + appDefinitionName, + appDefinitionSlug, + type AppGalleryDisplayEntry, +} from "./app-definition-display"; import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs"; import { SetupPanel } from "./app-detail/SetupPanel"; import { PermissionsPanel } from "./app-detail/PermissionsPanel"; @@ -147,7 +152,7 @@ export function AppDetail() { return labels; }, [userDirectoryQuery.data, sessionQuery.data]); const logoEntry = useMemo( - () => galleryEntryFor(galleryQuery.data?.apps ?? [], connection), + () => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection), [galleryQuery.data, connection], ); @@ -479,7 +484,7 @@ function AppDetailHeader({ }: { appName: string; connection: ToolConnection; - logoEntry: AppGalleryEntry | null; + logoEntry: AppGalleryDisplayEntry | null; status: StatusInfo; actionCount: number; renaming: boolean; @@ -493,7 +498,7 @@ function AppDetailHeader({ return (
- +
{renaming ? (
a.name.toLowerCase() === name) ?? apps.find((a) => a.key === name) ?? null; + return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ?? + apps.find((app) => appDefinitionSlug(app) === name) ?? + null; } function addAll(set: Set, ids: string[]): Set { diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx index b3ed082022..94fc8ee89d 100644 --- a/ui/src/pages/apps/AppNotConnected.tsx +++ b/ui/src/pages/apps/AppNotConnected.tsx @@ -12,6 +12,12 @@ import { agentsApi } from "@/api/agents"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { AppLogo } from "./AppLogo"; +import { + appDefinitionLogoUrl, + appDefinitionName, + appDefinitionSlug, + type AppGalleryDisplayEntry, +} from "./app-definition-display"; import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDetail"; import { ActivityPanel } from "./app-detail/ActivityPanel"; import { ReviewPanel } from "./app-detail/ReviewPanel"; @@ -121,10 +127,14 @@ export function AppNotConnected() { return ; } - const gallery = galleryQuery.data?.apps ?? []; + const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]; const logoUrl = - (application.applicationKey ? gallery.find((entry) => entry.key === application.applicationKey)?.logoUrl : undefined) ?? - gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.logoUrl; + (application.applicationKey + ? appDefinitionLogoUrl(gallery.find((entry) => appDefinitionSlug(entry) === application.applicationKey)) + : undefined) ?? + appDefinitionLogoUrl( + gallery.find((entry) => appDefinitionName(entry).toLowerCase() === application.name.toLowerCase()), + ); const previousAddress = previousConnection ? connectionAddress(previousConnection) : null; const connectHref = reconnectHref({ diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index d9ff322860..ac2b0fde6c 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppsConnect } from "./AppsConnect"; @@ -15,6 +16,9 @@ const mockNavigate = vi.hoisted(() => vi.fn()); const mockSearch = vi.hoisted(() => ({ value: "" })); const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined })); +const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!; +const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; + vi.mock("@/api/tools", () => ({ toolsApi: { listGallery: (companyId: string) => listGalleryMock(companyId), @@ -122,15 +126,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { document.body.appendChild(container); listGalleryMock.mockResolvedValue({ apps: [ - { - key: "zapier", - name: "Zapier", - tagline: "Automate things", - authKind: "api_key", - urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"], - logoUrl: null, - credentialFields: [{ configPath: "credentials.authorization", label: "API key", required: true }], - }, + ZAPIER, ], }); finishAppMock.mockResolvedValue({}); @@ -280,15 +276,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "byo=1&source=zapier"; listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "zapier", - name: "Zapier", - tagline: "Automate things", - authKind: "api_key", - urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"], - logoUrl: "https://example.com/zapier.png", - credentialFields: [], - }, + { ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } }, ], }); connectAppMock.mockResolvedValueOnce({ @@ -475,16 +463,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("shows the Google Sheets robot email and keeps empty sheet links from continuing", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); @@ -505,16 +484,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("shows inline validation for invalid Google Sheets links", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); @@ -615,16 +585,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("a custom name on the Google Sheets step is sent to the connect mutation", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); @@ -659,16 +620,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("passes parsed Google Sheets IDs as connection config values", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 227f68ff22..0b1783d34f 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -15,11 +15,11 @@ import { import type { LucideIcon } from "lucide-react"; import type { Agent, - AppGalleryEntry, + AppDefinition, ConnectToolAppResult, ToolAppConnectionActionSummary, } from "@paperclipai/shared"; -import { getToolAppGalleryEntryForUrl } from "@paperclipai/shared"; +import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod } from "@paperclipai/shared"; import { useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -83,8 +83,8 @@ function askFirstLevelsFrom(result: ConnectToolAppResult): string[] { return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"]; } -function isGoogleSheetsEntry(entry: AppGalleryEntry | null): boolean { - return entry?.key === "google-sheets"; +function isGoogleSheetsEntry(entry: AppDefinition | null): boolean { + return entry?.slug === "google-sheets"; } export function AppsConnect() { @@ -109,7 +109,7 @@ export function AppsConnect() { }); const [step, setStep] = useState(appKey || prefill.link || zapierSource ? "key" : "gallery"); - const [entry, setEntry] = useState(null); + const [entry, setEntry] = useState(null); const [galleryName, setGalleryName] = useState(""); const [linkUrl, setLinkUrl] = useState(prefill.link); const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : "")); @@ -160,15 +160,15 @@ export function AppsConnect() { useEffect(() => { if (!appKey || galleryQuery.isLoading || !galleryQuery.data) return; - const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.key === appKey); - if (!requestedEntry || requestedEntry.authKind === "oauth" || requestedEntry.availability?.available === false) { + const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === appKey); + if (!requestedEntry || getAvailableConnectionMethod(requestedEntry)?.auth === "oauth" || requestedEntry.availability?.available === false) { setEntry(null); setStep("gallery"); navigate("/apps/connect", { replace: true }); return; } - if (entry?.key !== requestedEntry.key) { + if (entry?.slug !== requestedEntry.slug) { setEntry(requestedEntry); setGalleryName(requestedEntry.name); setLinkUrl(""); @@ -183,11 +183,11 @@ export function AppsConnect() { setInstallMode("none"); setInstallAgentIds(new Set()); setStep("key"); - }, [appKey, entry?.key, galleryQuery.data, galleryQuery.isLoading, navigate]); + }, [appKey, entry?.slug, galleryQuery.data, galleryQuery.isLoading, navigate]); const setAppStep = (nextStep: Step) => { setStep(nextStep); - if (entry) navigate(appConnectHref(entry.key, nextStep)); + if (entry) navigate(appConnectHref(entry.slug, nextStep)); }; const connectMutation = useMutation({ @@ -196,7 +196,7 @@ export function AppsConnect() { const sheetIds = isGoogleSheetsEntry(entry) ? parseGoogleSheetIds(googleSheetsLinks).ids : []; const trimmedGalleryName = galleryName.trim(); return toolsApi.connectApp(selectedCompanyId!, { - galleryKey: entry.key, + galleryKey: entry.slug, name: trimmedGalleryName || undefined, credentialValues: credentials, configValues: isGoogleSheetsEntry(entry) ? { allowedSpreadsheetIds: sheetIds } : undefined, @@ -284,7 +284,7 @@ export function AppsConnect() { entry?.name ?? (linkName.trim() || defaultLinkName(linkUrl) || "this app"); const zapierEntry = zapierSource - ? galleryQuery.data?.apps.find((app) => app.key === "zapier") ?? null + ? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null : null; const stepLabels = zapierSource ? ZAPIER_STEP_LABELS @@ -311,7 +311,7 @@ export function AppsConnect() { labels={stepLabels} appIdentity={ zapierSource - ? { name: "Zapier", logoUrl: zapierEntry?.logoUrl ?? null } + ? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null } : undefined } onCancel={() => navigate(zapierSource ? "/apps/browse" : "/apps")} @@ -338,10 +338,10 @@ export function AppsConnect() { setInstallMode("none"); setInstallAgentIds(new Set()); setStep("key"); - navigate(appConnectHref(picked.key, "key")); + navigate(appConnectHref(picked.slug, "key")); }} onUseLink={(url) => { - const matchedEntry = getToolAppGalleryEntryForUrl(url, galleryQuery.data?.apps ?? []); + const matchedEntry = getAppDefinitionForUrl(url, galleryQuery.data?.apps ?? []); setEntry(null); setGalleryName(""); setLinkUrl(url); @@ -470,7 +470,7 @@ export function AppsConnect() { {step === "success" && ( void; + onPick: (entry: AppDefinition) => void; onUseLink: (link: string) => void; onRunYourOwn: () => void; onPasteConfig: () => void; @@ -635,7 +635,7 @@ function GalleryStep({ return apps.filter((a) => a.name.toLowerCase().includes(q)); }, [apps, search]); const normalizedLink = normalizeAppLink(linkInput); - const matchedEntry = normalizedLink ? getToolAppGalleryEntryForUrl(normalizedLink, apps) : null; + const matchedEntry = normalizedLink ? getAppDefinitionForUrl(normalizedLink, apps) : null; const zapierSource = source === "zapier"; const continueWithLink = () => { @@ -672,12 +672,12 @@ function GalleryStep({
{filtered.map((app) => { - const copy = appCopyFor(app.key, app.tagline); - const oauth = app.authKind === "oauth"; + const copy = appCopyFor(app.slug, app.description); + const oauth = getAvailableConnectionMethod(app)?.auth === "oauth"; const unavailable = app.availability?.available === false; return ( @@ -1037,7 +1037,7 @@ function KeyStep({ onBack, onConnect, }: { - entry: AppGalleryEntry; + entry: AppDefinition; name: string; onNameChange: (next: string) => void; values: Record; @@ -1049,8 +1049,13 @@ function KeyStep({ onBack: () => void; onConnect: () => void; }) { - const copy = appCopyFor(entry.key, entry.tagline); - const fields = entry.credentialFields ?? []; + const copy = appCopyFor(entry.slug, entry.description); + const method = getAvailableConnectionMethod(entry); + const fields = (method?.credentialFields ?? []).map((field) => ({ + ...field, + configPath: credentialConfigPath(field), + helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", + })); const allFilled = fields.every( (f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0, ); @@ -1063,7 +1068,7 @@ function KeyStep({ return (
- +

Connect Google Sheets

{copy.short}

@@ -1136,7 +1141,7 @@ function KeyStep({ return (
- +

Connect {entry.name}

{copy.short}

diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 5998e0abf3..9a764670b5 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link2, Search } from "lucide-react"; -import type { AppGalleryEntry } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -9,6 +8,13 @@ import { queryKeys } from "@/lib/queryKeys"; import { toolsApi } from "@/api/tools"; import { Skeleton } from "@/components/ui/skeleton"; import { AppLogo } from "./AppLogo"; +import { + appDefinitionDescription, + appDefinitionLogoUrl, + appDefinitionName, + appDefinitionSlug, + type AppGalleryDisplayEntry, +} from "./app-definition-display"; import { AdvancedToolsLink, BYO_CONNECT_HREF, @@ -46,11 +52,11 @@ export function Browse() { enabled: !!selectedCompanyId, }); - const gallery = galleryQuery.data?.apps ?? []; + const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]; const popular = useMemo( () => - POPULAR_KEYS.map((key) => gallery.find((entry) => entry.key === key)).filter( - (entry): entry is AppGalleryEntry => Boolean(entry), + POPULAR_KEYS.map((key) => gallery.find((entry) => appDefinitionSlug(entry) === key)).filter( + (entry): entry is AppGalleryDisplayEntry => Boolean(entry), ), [gallery], ); @@ -60,9 +66,8 @@ export function Browse() { if (!trimmed) return gallery; return gallery.filter( (entry) => - entry.name.toLowerCase().includes(trimmed) || - entry.tagline.toLowerCase().includes(trimmed) || - (entry.description?.toLowerCase().includes(trimmed) ?? false), + appDefinitionName(entry).toLowerCase().includes(trimmed) || + appDefinitionDescription(entry).toLowerCase().includes(trimmed), ); }, [gallery, trimmed]); @@ -109,9 +114,9 @@ export function Browse() {
{popular.map((entry) => ( navigate(ZAPIER_CONNECT_HREF) : undefined} + onConnect={appDefinitionSlug(entry) === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined} compact /> ))} @@ -132,9 +137,9 @@ export function Browse() {
{filtered.map((entry) => ( navigate(ZAPIER_CONNECT_HREF) : undefined} + onConnect={appDefinitionSlug(entry) === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined} /> ))}
@@ -160,7 +165,7 @@ function AppTile({ onConnect, compact = false, }: { - entry: AppGalleryEntry; + entry: AppGalleryDisplayEntry; onConnect?: () => void; compact?: boolean; }) { @@ -175,8 +180,8 @@ function AppTile({ ? "flex cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60" : "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center transition-colors hover:border-foreground/30 hover:bg-accent/40"} > - - {entry.name} + + {appDefinitionName(entry)} {disabled ? "Coming soon" : "Connect →"} @@ -192,10 +197,10 @@ function AppTile({ ? "flex h-full cursor-not-allowed items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60" : "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"} > - +
-
{entry.name}
-
{entry.tagline}
+
{appDefinitionName(entry)}
+
{appDefinitionDescription(entry)}
{disabled ? "Coming soon" : "Connect →"} diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx index d5bf011a64..6a330e107a 100644 --- a/ui/src/pages/apps/Connections.tsx +++ b/ui/src/pages/apps/Connections.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { AppWindow, ShieldAlert, ShieldQuestion } from "lucide-react"; import type { - AppGalleryEntry, ToolApplication, ToolConnection, ToolProfileWithDetails, @@ -21,6 +20,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { timeAgo } from "@/lib/timeAgo"; import { AppLogo } from "./AppLogo"; +import { + appDefinitionLogoUrl, + appDefinitionName, + appDefinitionSlug, + type AppGalleryDisplayEntry, +} from "./app-definition-display"; import { useReviewCount } from "./useReviewCount"; import { AdvancedToolsLink } from "./store-cards"; @@ -113,15 +118,15 @@ export function Connections() { enabled: !!selectedCompanyId, }); - const gallery = galleryQuery.data?.apps ?? []; + const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]; const logoByName = useMemo(() => { - const map = new Map(); - for (const entry of gallery) map.set(entry.name.toLowerCase(), entry); + const map = new Map(); + for (const entry of gallery) map.set(appDefinitionName(entry).toLowerCase(), entry); return map; }, [gallery]); const logoByKey = useMemo(() => { - const map = new Map(); - for (const entry of gallery) map.set(entry.key, entry); + const map = new Map(); + for (const entry of gallery) map.set(appDefinitionSlug(entry), entry); return map; }, [gallery]); @@ -173,7 +178,8 @@ export function Connections() { status: statusFor(application, appConnections), actionCount, lastUsedAt, - logoUrl: galleryEntry?.logoUrl ?? logoByName.get(application.name.toLowerCase())?.logoUrl, + logoUrl: appDefinitionLogoUrl(galleryEntry) ?? + appDefinitionLogoUrl(logoByName.get(application.name.toLowerCase())), }; }); }, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]); diff --git a/ui/src/pages/apps/app-definition-display.ts b/ui/src/pages/apps/app-definition-display.ts new file mode 100644 index 0000000000..0e6ab9e0e0 --- /dev/null +++ b/ui/src/pages/apps/app-definition-display.ts @@ -0,0 +1,24 @@ +import type { AppDefinition } from "@paperclipai/shared"; + +export type AppGalleryDisplayEntry = AppDefinition & { + key?: string; + logoUrl?: string; + tagline?: string; + branding?: AppDefinition["branding"]; +}; + +export function appDefinitionSlug(entry: AppGalleryDisplayEntry | null | undefined): string { + return entry?.slug ?? entry?.key ?? ""; +} + +export function appDefinitionName(entry: AppGalleryDisplayEntry | null | undefined): string { + return entry?.name ?? appDefinitionSlug(entry) ?? "App"; +} + +export function appDefinitionDescription(entry: AppGalleryDisplayEntry | null | undefined): string { + return entry?.description ?? entry?.tagline ?? ""; +} + +export function appDefinitionLogoUrl(entry: AppGalleryDisplayEntry | null | undefined): string | undefined { + return entry?.branding?.logoUrl ?? entry?.logoUrl; +} diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index 994120eecc..bc0c2e3c3c 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { ArrowUpRight, Loader2, Lock } from "lucide-react"; -import type { AppGalleryEntry, ToolConnection } from "@paperclipai/shared"; -import { humanizeConnectionDisplayName } from "@paperclipai/shared"; +import type { AppDefinition, ToolConnection } from "@paperclipai/shared"; +import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared"; import { toolsApi } from "@/api/tools"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -37,7 +37,7 @@ function KeySection({ onReplaced, }: { connection: ToolConnection; - galleryEntry: AppGalleryEntry | null; + galleryEntry: AppDefinition | null; onReplaced: () => void; }) { const [open, setOpen] = useState(false); @@ -82,7 +82,7 @@ export function ReconnectCard({ onReconnected, }: { connection: ToolConnection; - galleryEntry: AppGalleryEntry | null; + galleryEntry: AppDefinition | null; onReconnected: () => void; }) { return ( @@ -105,12 +105,19 @@ function ReconnectForm({ onReconnected, }: { connection: ToolConnection; - galleryEntry: AppGalleryEntry | null; + galleryEntry: AppDefinition | null; onCancel?: () => void; onReconnected: () => void; }) { const { pushToast } = useToast(); - const fields = galleryEntry?.credentialFields ?? []; + const method = galleryEntry && Array.isArray(galleryEntry.methods) + ? getAvailableConnectionMethod(galleryEntry) + : null; + const fields = (method?.credentialFields ?? []).map((field) => ({ + ...field, + configPath: credentialConfigPath(field), + helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", + })); const [values, setValues] = useState>({}); const [single, setSingle] = useState(""); const usesGallery = fields.length > 0 && !!galleryEntry; diff --git a/ui/src/pages/apps/app-detail/SetupPanel.tsx b/ui/src/pages/apps/app-detail/SetupPanel.tsx index a8e66fb4e8..6d03124296 100644 --- a/ui/src/pages/apps/app-detail/SetupPanel.tsx +++ b/ui/src/pages/apps/app-detail/SetupPanel.tsx @@ -3,6 +3,7 @@ import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { appDefinitionSlug } from "../app-definition-display"; import type { AppDetailSectionProps } from "./types"; import { googleSheetsConfigWithAllowlist, parseGoogleSheetIds } from "../google-sheets"; @@ -26,7 +27,7 @@ export function SetupPanel({ onStartOAuth: () => void; oauthStartDisabled: boolean; }) { - const description = galleryEntry?.description ?? galleryEntry?.tagline ?? null; + const description = galleryEntry?.description ?? null; const oauth = connection.config?.oauth; const hasOAuthSignIn = Boolean(oauth && typeof oauth === "object" && !Array.isArray(oauth)); const isSmokeLabFixture = connection.config?.smokeLabFixture === "oauth-http"; @@ -35,7 +36,7 @@ export function SetupPanel({ {description && (

{description}

)} - {galleryEntry?.key === "google-sheets" && ( + {appDefinitionSlug(galleryEntry) === "google-sheets" && ( e.key === "zapier") as AppGalleryEntry; -const GALLERY: AppGalleryEntry[] = [ZAPIER]; +const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((e) => e.slug === "zapier") as AppDefinition; +const GALLERY: AppDefinition[] = [ZAPIER]; function seededClient() { const c = new QueryClient({