feat(connections): add AppDefinition Wave 1 catalog (#9981)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Connections is the subsystem that defines which external apps and
MCP-style integrations operators can browse, configure, and run
> - The v3 schema core in #9958 added stable connection identities, auth
metadata, and grant-aware contracts, but the app catalog still used the
older gallery shape
> - The product needs a richer, typed AppDefinition catalog so browsing
and setup can render provider-specific auth and configuration
requirements consistently
> - This pull request moves the Wave 1 app catalog onto generated
AppDefinition data and carries that shape through shared types, server
lookup paths, and app connection UI
> - The benefit is that follow-up runtime and wizard work can build
against one catalog contract instead of local-only mock/gallery data

## Linked Issues or Issue Description

Refs #9958.

No public GitHub issue exists for this branch. This is the catalog layer
for the Connections v3 stack after the schema-core foundation in #9958.

## What Changed

- Adds generated AppDefinition data for the Wave 1 catalog and ingestion
reporting.
- Replaces the legacy tool app gallery exports with
AppDefinition-centered shared contracts, validators, and tests.
- Updates server tool-access lookup behavior to use the AppDefinition
catalog.
- Updates app connection UI surfaces and tests to consume
AppDefinition-backed catalog data.
- Documents the catalog ingestion workflow in the connector playbook.

## Verification

- `pnpm run preflight:workspace-links`
- `pnpm exec vitest run packages/shared/src/app-definitions.test.ts
packages/shared/src/app-definitions-url.test.ts
ui/src/pages/apps/AppsConnect.test.tsx
server/src/__tests__/tool-access-service.test.ts`

## Risks

- Medium: this changes the catalog contract used by shared, server, and
UI app connection surfaces.
- Catalog data quality matters because generated definitions now drive
browse/setup display.
- Follow-up runtime and wizard PRs must rebase on this branch or on
master after this lands.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex coding agent with repository tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-21 15:57:12 -05:00 committed by GitHub
parent 7e00f67138
commit d23fbf8ae4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 3520 additions and 483 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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);
}
});
});

View File

@ -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[];

File diff suppressed because it is too large Load Diff

View File

@ -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()}});
});

View File

@ -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<ToolConnectionOwnership, boolean> = {
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<string, unknown> {
const method = getAvailableConnectionMethod(app);
return {
access: "all_agents",
askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"],
};
}

View File

@ -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"
}
}
]
}

View File

@ -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 "
}
}
]
}

View File

@ -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"
}
]
}

View File

@ -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"
]
}
]
}

View File

@ -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"
]
}
]
}

View File

@ -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"
]
}
]
}

View File

@ -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"
]
}
]
}

View File

@ -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
}
]
}
]
}

View File

@ -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"
]
}
]
}

View File

@ -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"
]
}
]
}

View File

@ -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"
]
}
]
}

View File

@ -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 "
}
}
]
}

View File

@ -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";

View File

@ -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);
}
});
});

View File

@ -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<ToolConnectionTransport, "mcp_remote">;
url: string;
}
| {
transport: Extract<ToolConnectionTransport, "local_stdio">;
templateKey: string;
};
export interface AppGalleryEntry {
key: string;
name: string;
logoUrl: string;
tagline: string;
description?: string;
authKind: AppGalleryAuthKind;
transportTemplate: AppGalleryTransportTemplate;
credentialFields: AppGalleryCredentialField[];
recommendedDefaults: Record<string, unknown>;
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;
}

View File

@ -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<Record<ToolConnectionOwnership,boolean>> }

View File

@ -874,3 +874,4 @@ export type {
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
} from "./plugin.js";
export * from "./app-definition.js";

View File

@ -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<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});

View File

@ -870,3 +870,4 @@ export {
type RevokeToolTrustRule,
} from "./tool-access.js";
export * from "./skill-policy.js";
export * from "./app-definition.js";

View File

@ -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.`);

View File

@ -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 }),
}),
]),
);

View File

@ -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 },
),
});
});

View File

@ -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<string, unknown> | 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<ReturnType<typeof getToolAppGalleryEntry>>): Promise<OAuthProviderEndpoints> {
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<OAuthProviderEndpoints> {
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<OAuthProviderEndpoints> {
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<ConnectToolAppResult> {
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<string, unknown> = 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"],
},

View File

@ -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<CreateToolMcpGatewayToken, "expiresAt"> & {
expiresAt?: string | Date | null;

View File

@ -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) {
<span className="truncate">All apps</span>
</Link>
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={28} />
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(logoEntry)} size={28} />
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
</div>
</div>
@ -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 {

View File

@ -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 (
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={44} />
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(logoEntry)} size={44} />
<div>
{renaming ? (
<form
@ -607,10 +612,15 @@ function accessFrom(profile: ToolProfileWithDetails | undefined): AccessDraft {
return { mode: "specific", agentIds };
}
function galleryEntryFor(apps: AppGalleryEntry[], connection: ToolConnection | undefined): AppGalleryEntry | null {
function galleryEntryFor(
apps: AppGalleryDisplayEntry[],
connection: ToolConnection | undefined,
): AppGalleryDisplayEntry | null {
if (!connection) return null;
const name = connection.name.toLowerCase();
return apps.find((a) => 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<string>, ids: string[]): Set<string> {

View File

@ -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 <Navigate to={appTabHref(activeConnection.id, activeTab)} replace />;
}
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({

View File

@ -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();

View File

@ -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<Step>(appKey || prefill.link || zapierSource ? "key" : "gallery");
const [entry, setEntry] = useState<AppGalleryEntry | null>(null);
const [entry, setEntry] = useState<AppDefinition | null>(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" && (
<SuccessStep
appName={appName}
logoUrl={entry?.logoUrl}
logoUrl={entry?.branding.logoUrl}
enabledCount={Object.values(enabled).filter(Boolean).length}
access={access}
installMode={installMode}
@ -607,11 +607,11 @@ function GalleryStep({
onPasteConfig,
}: {
loading: boolean;
apps: AppGalleryEntry[];
apps: AppDefinition[];
/** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
byo?: boolean;
source?: string | null;
onPick: (entry: AppGalleryEntry) => 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({
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{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 (
<button
key={app.key}
key={app.slug}
type="button"
disabled={oauth || unavailable}
title={
@ -691,7 +691,7 @@ function GalleryStep({
oauth || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
)}
>
<AppLogo name={app.name} logoUrl={app.logoUrl} size={36} />
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
<div className="mt-3 text-xs font-semibold text-foreground">
@ -740,7 +740,7 @@ function GalleryStep({
{matchedEntry && (
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-sm">
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.logoUrl} size={24} />
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} size={24} />
<span className="truncate">This looks like {matchedEntry.name}.</span>
</div>
<Button
@ -750,7 +750,7 @@ function GalleryStep({
disabled={matchedEntry.availability?.available === false}
onClick={() => {
setLinkError(null);
if (matchedEntry.key === "zapier") {
if (matchedEntry.slug === "zapier") {
continueWithLink();
return;
}
@ -759,7 +759,7 @@ function GalleryStep({
>
{matchedEntry.availability?.available === false
? "Not available"
: matchedEntry.key === "zapier"
: matchedEntry.slug === "zapier"
? "Continue"
: `Use ${matchedEntry.name}`}
</Button>
@ -1037,7 +1037,7 @@ function KeyStep({
onBack,
onConnect,
}: {
entry: AppGalleryEntry;
entry: AppDefinition;
name: string;
onNameChange: (next: string) => void;
values: Record<string, string>;
@ -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 (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-center gap-3">
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={48} />
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={48} />
<div>
<h2 className="text-lg font-bold tracking-tight sm:text-xl">Connect Google Sheets</h2>
<p className="text-sm text-muted-foreground">{copy.short}</p>
@ -1136,7 +1141,7 @@ function KeyStep({
return (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-center gap-3">
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={48} />
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={48} />
<div>
<h2 className="text-xl font-bold tracking-tight">Connect {entry.name}</h2>
<p className="text-sm text-muted-foreground">{copy.short}</p>

View File

@ -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() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{popular.map((entry) => (
<AppTile
key={entry.key}
key={appDefinitionSlug(entry)}
entry={entry}
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
onConnect={appDefinitionSlug(entry) === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
compact
/>
))}
@ -132,9 +137,9 @@ export function Browse() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((entry) => (
<AppTile
key={entry.key}
key={appDefinitionSlug(entry)}
entry={entry}
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
onConnect={appDefinitionSlug(entry) === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
/>
))}
</div>
@ -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"}
>
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
<span className="text-xs font-medium text-foreground">{entry.name}</span>
<AppLogo name={appDefinitionName(entry)} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<span className="text-xs font-medium text-foreground">{appDefinitionName(entry)}</span>
<span className={disabled ? "text-xs text-muted-foreground" : "text-xs font-semibold text-primary"}>
{disabled ? "Coming soon" : "Connect →"}
</span>
@ -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"}
>
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
<AppLogo name={appDefinitionName(entry)} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">{entry.name}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{entry.tagline}</div>
<div className="text-sm font-semibold text-foreground">{appDefinitionName(entry)}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{appDefinitionDescription(entry)}</div>
</div>
<span className={disabled ? "shrink-0 text-xs font-semibold text-muted-foreground" : "shrink-0 text-xs font-semibold text-primary"}>
{disabled ? "Coming soon" : "Connect →"}

View File

@ -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<string, AppGalleryEntry>();
for (const entry of gallery) map.set(entry.name.toLowerCase(), entry);
const map = new Map<string, AppGalleryDisplayEntry>();
for (const entry of gallery) map.set(appDefinitionName(entry).toLowerCase(), entry);
return map;
}, [gallery]);
const logoByKey = useMemo(() => {
const map = new Map<string, AppGalleryEntry>();
for (const entry of gallery) map.set(entry.key, entry);
const map = new Map<string, AppGalleryDisplayEntry>();
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]);

View File

@ -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;
}

View File

@ -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<Record<string, string>>({});
const [single, setSingle] = useState("");
const usesGallery = fields.length > 0 && !!galleryEntry;

View File

@ -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 && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
)}
{galleryEntry?.key === "google-sheets" && (
{appDefinitionSlug(galleryEntry) === "google-sheets" && (
<GoogleSheetsAllowlistSection
connection={connection}
disabled={configUpdateDisabled}

View File

@ -1,6 +1,6 @@
import type {
Agent,
AppGalleryEntry,
AppDefinition,
ToolCallEvent,
ToolConnectionActivityResponse,
ToolConnectionLifecycleEvent,
@ -14,7 +14,7 @@ export interface AppDetailSectionProps {
connectionId: string;
connection: ToolConnection;
appName: string;
galleryEntry: AppGalleryEntry | null;
galleryEntry: AppDefinition | null;
catalog: ToolCatalogEntry[];
active: ToolCatalogEntry[];
readOnly: ToolCatalogEntry[];

View File

@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
TOOL_APP_GALLERY,
type AppGalleryEntry,
CONNECTABLE_APP_DEFINITIONS,
type AppDefinition,
type McpJsonImportPreview,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
@ -23,7 +23,7 @@ import { PasteConfigTab } from "@/pages/tools/PasteConfigTab";
const COMPANY = "company-storybook";
const GALLERY: AppGalleryEntry[] = TOOL_APP_GALLERY.slice(0, 6) as AppGalleryEntry[];
const GALLERY: AppDefinition[] = CONNECTABLE_APP_DEFINITIONS.slice(0, 6) as AppDefinition[];
function seededClient() {
const c = new QueryClient({

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TOOL_APP_GALLERY, type AppGalleryEntry } from "@paperclipai/shared";
import { CONNECTABLE_APP_DEFINITIONS, type AppDefinition } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { AppsConnect } from "@/pages/apps/AppsConnect";
@ -17,8 +17,8 @@ const COMPANY = "company-storybook";
// Zapier is an api_key gallery app, so the key step renders both the new Name
// field and a credential input — a representative shape for this screenshot.
const ZAPIER = TOOL_APP_GALLERY.find((e) => 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({