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:
parent
7e00f67138
commit
d23fbf8ae4
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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()}});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 "
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 "
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>> }
|
||||
|
|
@ -874,3 +874,4 @@ export type {
|
|||
PluginDatabaseNamespaceMode,
|
||||
PluginDatabaseNamespaceStatus,
|
||||
} from "./plugin.js";
|
||||
export * from "./app-definition.js";
|
||||
|
|
|
|||
|
|
@ -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)})});
|
||||
|
|
@ -870,3 +870,4 @@ export {
|
|||
type RevokeToolTrustRule,
|
||||
} from "./tool-access.js";
|
||||
export * from "./skill-policy.js";
|
||||
export * from "./app-definition.js";
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
@ -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 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 →"}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue