feat(connections): cut gallery over to AppDefinition

This commit is contained in:
Dotta committed 2026-07-21 20:17:24 +00:00
1 parent 9f9eb444e1
commit c2dc729769
23 files changed
+295 -474

No files matched your search

@@ -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);
}
});
});
+67
View File
@@ -0,0 +1,67 @@
import { APP_DEFINITIONS } from "./app-definitions.generated.js";
import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-definition.js";
import type { ToolConnectionOwnership } from "./types/tool-access.js";
const CONNECTABLE_APP_SLUGS = new Set([
"zapier",
"github",
"slack",
"notion",
"linear",
"google-sheets",
"context7",
]);
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
CONNECTABLE_APP_SLUGS.has(app.slug)
);
export const DEFAULT_OWNERSHIP_AVAILABILITY: Record<ToolConnectionOwnership, boolean> = {
platform_shared: false,
platform_provisioned: false,
customer: true,
dcr: true,
};
export function getConnectableAppDefinition(slug: string): AppDefinition | null {
return CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug) ?? null;
}
function wildcardPatternToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}
export function getAppDefinitionForUrl(
link: string,
definitions: readonly AppDefinition[] = CONNECTABLE_APP_DEFINITIONS,
): AppDefinition | null {
let normalized: string;
try {
normalized = new URL(link.trim()).toString();
} catch {
return null;
}
return definitions.find((app) =>
app.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
) ?? null;
}
export function getAvailableConnectionMethod(app: AppDefinition): ConnectionMethodDef | null {
const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY;
return app.methods.find((method) =>
method.ownershipModes.some((ownership) => availability[ownership] !== false)
) ?? null;
}
export function credentialConfigPath(field: FieldDef): string {
return `credentials.${field.key}`;
}
export function recommendedDefaultsForApp(app: AppDefinition): Record<string, unknown> {
const method = getAvailableConnectionMethod(app);
return {
access: "all_agents",
askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"],
};
}
@@ -30,7 +30,7 @@
"credentialFields": [
{
"key": "authorization",
"label": "Zapier token",
"label": "Zapier MCP token",
"type": "password",
"required": true,
"placeholder": "Paste your Zapier token",
+11 -9
View File
@@ -143,15 +143,14 @@ 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 {
@@ -1259,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);
}
});
});
-243
View File
@@ -1,243 +0,0 @@
import type { ToolConnectionTransport } from "./types/tool-access.js";
export type AppGalleryAuthKind = "oauth" | "api_key" | "none";
export interface AppGalleryCredentialField {
label: string;
configPath: string;
helpUrl: string;
required?: boolean;
placement?: "header" | "env";
key?: string;
prefix?: string | null;
}
export type AppGalleryTransportTemplate =
| {
transport: Extract<ToolConnectionTransport, "mcp_remote">;
url: string;
}
| {
transport: Extract<ToolConnectionTransport, "local_stdio">;
templateKey: string;
};
export interface AppGalleryEntry {
key: string;
name: string;
logoUrl: string;
tagline: string;
description?: string;
authKind: AppGalleryAuthKind;
transportTemplate: AppGalleryTransportTemplate;
credentialFields: AppGalleryCredentialField[];
recommendedDefaults: Record<string, unknown>;
urlPatterns: string[];
availability?: {
available: boolean;
reason?: string | null;
robotEmail?: string | null;
};
oauth?: {
provider: string;
scopes: string[];
tokenUrl?: string | null;
metadataUrl?: string | null;
authorizationUrl?: string | null;
};
}
const favicon = (domain: string) => `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
export const TOOL_APP_GALLERY = [
{
key: "zapier",
name: "Zapier",
logoUrl: favicon("zapier.com"),
tagline: "Connect Zapier-hosted actions to your Paperclip agents.",
description: "Let agents use Zapier automations across the apps your business already runs. Good for handoffs, lightweight operations, and cross-app updates that should stay visible in Paperclip.",
authKind: "api_key",
transportTemplate: {
transport: "mcp_remote",
url: "https://mcp.zapier.com/api/mcp",
},
credentialFields: [
{
label: "Zapier MCP token",
configPath: "credentials.authorization",
helpUrl: "https://zapier.com/app/settings/authorizations",
required: true,
placement: "header",
key: "Authorization",
prefix: "Bearer ",
},
],
recommendedDefaults: {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
urlPatterns: ["https://mcp.zapier.com/*"],
},
{
key: "github",
name: "GitHub",
logoUrl: favicon("github.com"),
tagline: "Read and manage GitHub issues and pull requests.",
description: "Give agents a governed way to inspect repositories, issues, and pull requests. Useful when engineering work needs GitHub context or small updates without leaving Paperclip.",
authKind: "api_key",
transportTemplate: {
transport: "mcp_remote",
url: "https://api.githubcopilot.com/mcp/",
},
credentialFields: [
{
label: "GitHub token",
configPath: "credentials.authorization",
helpUrl: "https://github.com/settings/tokens",
required: true,
placement: "header",
key: "Authorization",
prefix: "Bearer ",
},
],
recommendedDefaults: {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
urlPatterns: ["https://api.githubcopilot.com/mcp/*"],
},
{
key: "slack",
name: "Slack",
logoUrl: favicon("slack.com"),
tagline: "Search channels and coordinate Slack actions.",
description: "Let agents search workspace conversations and coordinate in Slack when work needs team context. Message-sending actions can still ask a human first.",
authKind: "oauth",
transportTemplate: {
transport: "mcp_remote",
url: "https://mcp.slack.com/mcp",
},
credentialFields: [],
recommendedDefaults: {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
urlPatterns: ["https://mcp.slack.com/*"],
oauth: {
provider: "slack",
scopes: ["channels:read", "chat:write", "search:read"],
authorizationUrl: "https://slack.com/oauth/v2/authorize",
tokenUrl: "https://slack.com/api/oauth.v2.access",
},
},
{
key: "notion",
name: "Notion",
logoUrl: favicon("notion.so"),
tagline: "Search and update Notion workspace content.",
description: "Connect Notion so agents can find docs, read project notes, and update workspace pages. Use it for company memory that lives outside Paperclip.",
authKind: "oauth",
transportTemplate: {
transport: "mcp_remote",
url: "https://mcp.notion.com/mcp",
},
credentialFields: [],
recommendedDefaults: {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
urlPatterns: ["https://mcp.notion.com/*"],
oauth: {
provider: "notion",
scopes: ["read_content", "update_content"],
authorizationUrl: "https://api.notion.com/v1/oauth/authorize",
tokenUrl: "https://api.notion.com/v1/oauth/token",
},
},
{
key: "linear",
name: "Linear",
logoUrl: favicon("linear.app"),
tagline: "Read and update Linear issues from agent workflows.",
description: "Let agents look up Linear work and make issue updates when their Paperclip tasks depend on your existing product queue.",
authKind: "oauth",
transportTemplate: {
transport: "mcp_remote",
url: "https://mcp.linear.app/mcp",
},
credentialFields: [],
recommendedDefaults: {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
urlPatterns: ["https://mcp.linear.app/*"],
oauth: {
provider: "linear",
scopes: ["read", "write"],
authorizationUrl: "https://linear.app/oauth/authorize",
tokenUrl: "https://api.linear.app/oauth/token",
},
},
{
key: "google-sheets",
name: "Google Sheets",
logoUrl: favicon("sheets.google.com"),
tagline: "Read and update selected spreadsheets.",
description: "Let agents read and update only the spreadsheets you choose. Share each sheet with the robot email, then paste the sheet links here.",
authKind: "none",
transportTemplate: {
transport: "local_stdio",
templateKey: "paperclip.google-sheets",
},
credentialFields: [],
recommendedDefaults: {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
urlPatterns: ["https://docs.google.com/spreadsheets/*", "https://sheets.google.com/*"],
},
{
key: "context7",
name: "Context7",
logoUrl: favicon("context7.com"),
tagline: "Fetch up-to-date library documentation with Context7.",
description: "Let agents pull current library documentation while they work. It is a low-risk reference app for coding and research tasks.",
authKind: "none",
transportTemplate: {
transport: "mcp_remote",
url: "https://mcp.context7.com/mcp",
},
credentialFields: [],
recommendedDefaults: {
access: "all_agents",
askFirstRiskLevels: [],
},
urlPatterns: ["https://mcp.context7.com/*"],
},
] satisfies AppGalleryEntry[];
export type AppGalleryKey = (typeof TOOL_APP_GALLERY)[number]["key"];
export function getToolAppGalleryEntry(key: string): AppGalleryEntry | null {
return TOOL_APP_GALLERY.find((entry) => entry.key === key) ?? null;
}
function wildcardPatternToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}
export function getToolAppGalleryEntryForUrl(
link: string,
entries: readonly AppGalleryEntry[] = TOOL_APP_GALLERY,
): AppGalleryEntry | null {
let normalized: string;
try {
normalized = new URL(link.trim()).toString();
} catch {
return null;
}
return entries.find((entry) =>
entry.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
) ?? null;
}
@@ -2,5 +2,5 @@ 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()});
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)})});
@@ -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 }),
}),
]),
);
+7 -5
View File
@@ -3,7 +3,8 @@ import type { Db } from "@paperclipai/db";
import { agents, companies } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import {
TOOL_APP_GALLERY,
CONNECTABLE_APP_DEFINITIONS,
DEFAULT_OWNERSHIP_AVAILABILITY,
TOOL_ACTION_REQUEST_STATUSES,
type DeploymentExposure,
type DeploymentMode,
@@ -218,15 +219,16 @@ export function toolAccessRoutes(
assertCompanyAccess(req, companyId);
const googleSheetsAvailability = googleSheetsRobotEmailFromEnv();
res.json({
apps: TOOL_APP_GALLERY.map((entry) =>
entry.key === "google-sheets"
apps: CONNECTABLE_APP_DEFINITIONS.map((app) =>
app.slug === "google-sheets"
? {
...entry,
...app,
ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY,
availability: googleSheetsAvailability.available
? { available: true, robotEmail: googleSheetsAvailability.robotEmail }
: { available: false, reason: googleSheetsAvailability.reason },
}
: entry,
: { ...app, ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY },
),
});
});
+53 -35
View File
@@ -33,6 +33,7 @@ import {
toolRuntimeSlots,
} from "@paperclipai/db";
import type {
AppDefinition,
ConnectionTokenIssuanceOutcome,
ConnectionTokenIssuancePath,
ConnectionTokenRequest,
@@ -104,7 +105,7 @@ import type {
UpdateToolProfileWithEntries,
UnbindToolProfileBinding,
} from "@paperclipai/shared";
import { CLASS3_STATIC_LEASE_ALLOWLIST, getToolAppGalleryEntry, isToolConnectionAttentionHealth } from "@paperclipai/shared";
import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared";
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js";
@@ -423,6 +424,25 @@ export function googleSheetsRobotEmailFromEnv(
return { available: false, reason: "Google Sheets is not available on this instance yet." };
}
function connectionMethodFor(app: AppDefinition) {
const method = getAvailableConnectionMethod(app);
if (!method) throw unprocessable("This app does not have an available connection method");
return method;
}
function credentialFieldsFor(app: AppDefinition) {
const method = connectionMethodFor(app);
return (method.credentialFields ?? []).map((field) => ({
label: field.label,
configPath: credentialConfigPath(field),
helpUrl: method.consoleLinks?.keys ?? method.consoleLinks?.docs ?? "",
required: field.required,
placement: method.keyPlacement?.location === "header" ? "header" as const : undefined,
key: method.keyPlacement?.name,
prefix: method.keyPlacement?.prefix,
}));
}
function googleSheetsAllowedSpreadsheetIds(configValues: Record<string, unknown> | undefined): string[] {
const raw = configValues?.allowedSpreadsheetIds;
const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : [];
@@ -3833,13 +3853,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return null;
}
async function oauthProviderEndpoints(galleryEntry: NonNullable<ReturnType<typeof getToolAppGalleryEntry>>): Promise<OAuthProviderEndpoints> {
const oauth = galleryEntry.oauth;
if (!oauth) throw unprocessable("This app does not support sign in");
let authorizationUrl = oauth.authorizationUrl ?? null;
let tokenUrl = oauth.tokenUrl ?? null;
if ((!authorizationUrl || !tokenUrl) && oauth.metadataUrl) {
const response = await fetchRemoteHttpUrl(oauth.metadataUrl);
async function oauthProviderEndpoints(app: AppDefinition): Promise<OAuthProviderEndpoints> {
const method = connectionMethodFor(app);
if (method.auth !== "oauth") throw unprocessable("This app does not support sign in");
let authorizationUrl = method.defaults?.authorizationEndpoint ?? null;
let tokenUrl = method.defaults?.tokenEndpoint ?? null;
const metadataUrl = method.defaults?.metadataUrl ?? null;
if ((!authorizationUrl || !tokenUrl) && metadataUrl) {
const response = await fetchRemoteHttpUrl(metadataUrl);
if (!response.ok) throw new HttpError(502, "OAuth provider metadata could not be loaded", { code: "oauth_metadata_failed" });
const metadata = asRecord(await response.json() as unknown);
authorizationUrl = authorizationUrl ?? (typeof metadata.authorization_endpoint === "string" ? metadata.authorization_endpoint : null);
@@ -3848,7 +3869,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
if (!authorizationUrl || !tokenUrl) {
throw unprocessable("OAuth provider endpoints are not configured for this app");
}
return { provider: oauth.provider, scopes: oauth.scopes, authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl: oauth.metadataUrl ?? null };
return { provider: app.slug, scopes: method.defaults?.scopesHint ?? [], authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl };
}
async function oauthEndpointsForConnection(
@@ -3858,9 +3879,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
): Promise<OAuthProviderEndpoints> {
const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri);
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null;
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
const endpoints = smokeLabEndpoints
?? (galleryEntry?.authKind === "oauth" && galleryEntry.oauth
?? (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth"
? await oauthProviderEndpoints(galleryEntry)
: await discoverOAuthEndpoints(connection, challenge));
if (!endpoints) throw unprocessable("This app connection does not advertise OAuth sign in");
@@ -3871,8 +3892,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
async function oauthGalleryEntryForConnection(connection: typeof toolConnections.$inferSelect) {
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
if (!sourceTemplateKey) throw unprocessable("This app connection was not created from the app gallery");
const galleryEntry = getToolAppGalleryEntry(sourceTemplateKey);
if (!galleryEntry || galleryEntry.authKind !== "oauth" || !galleryEntry.oauth) {
const galleryEntry = getConnectableAppDefinition(sourceTemplateKey);
if (!galleryEntry || connectionMethodFor(galleryEntry).auth !== "oauth") {
throw unprocessable("This app connection does not use sign in");
}
return galleryEntry;
@@ -4087,7 +4108,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
input: ConnectToolApp,
actor?: ActorInfo,
): Promise<ConnectToolAppResult> {
const galleryEntry = input.galleryKey ? getToolAppGalleryEntry(input.galleryKey) : null;
const galleryEntry = input.galleryKey ? getConnectableAppDefinition(input.galleryKey) : null;
if (input.galleryKey && !galleryEntry) throw notFound("Tool app gallery entry not found");
let existingApplication: typeof toolApplications.$inferSelect | null = null;
@@ -4101,18 +4122,15 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? "");
const transportTemplate = galleryEntry?.transportTemplate ?? {
transport: "mcp_remote" as const,
url: input.link ?? "",
};
const transport = transportTemplate.transport;
const method = galleryEntry ? connectionMethodFor(galleryEntry) : null;
const transport = method?.transport ?? "mcp_remote";
const baseConfig = transport === "mcp_remote"
? { url: transportTemplate.url }
: { templateId: transportTemplate.templateKey };
? { url: method?.defaults?.serverUrl ?? input.link ?? "" }
: { templateId: method?.defaults?.templateKey };
let config: Record<string, unknown> = galleryEntry
? { ...baseConfig, sourceTemplateKey: galleryEntry.key, quarantineNewEntries: true }
? { ...baseConfig, sourceTemplateKey: galleryEntry.slug, quarantineNewEntries: true }
: { ...baseConfig, quarantineNewEntries: true };
if (galleryEntry?.key === GOOGLE_SHEETS_GALLERY_KEY) {
if (galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY) {
const availability = googleSheetsRobotEmailFromEnv();
if (!availability.available) {
throw unprocessable(availability.reason, { code: "google_sheets_unavailable" });
@@ -4142,7 +4160,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = null;
try {
const credentialFields = galleryEntry?.credentialFields ?? linkCredentialFields(credentialValues);
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : linkCredentialFields(credentialValues);
for (const field of credentialFields) {
const value = credentialValues[field.configPath];
if (!value && field.required !== false) {
@@ -4188,12 +4206,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
} else {
[applicationRow] = await db.insert(toolApplications).values({
companyId,
applicationKey: `app-gallery:${galleryEntry?.key ?? "link"}:${randomUUID()}`,
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
name,
description: galleryEntry?.tagline ?? `Connected app at ${input.link}`,
description: galleryEntry?.description ?? `Connected app at ${input.link}`,
type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
status: "draft",
metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.key, galleryKey: galleryEntry.key } : { source: "link" },
metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" },
}).returning();
}
@@ -4235,7 +4253,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
name,
uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId),
connectionKind: "managed",
authKind: galleryEntry?.authKind ?? "none",
authKind: galleryEntry ? connectionMethodFor(galleryEntry).auth : "none",
transport,
status: "draft",
enabled: false,
@@ -4250,14 +4268,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
await syncCredentialBindings(connectionRow);
await ensureRuntimeSlot(connectionRow);
if (galleryEntry?.authKind === "oauth") {
if (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth") {
return {
connectionId: connectionRow.id,
application: toApplication(applicationRow),
connection: toConnection(connectionRow),
catalog: [],
actions: { readOnly: [], canMakeChanges: [] },
suggestedDefaults: galleryEntry.recommendedDefaults,
suggestedDefaults: recommendedDefaultsForApp(galleryEntry),
auth: { kind: "oauth", startUrl: null },
};
}
@@ -4292,7 +4310,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
connection: refresh.connection,
catalog: refresh.catalog,
actions: groupedActions(refresh.catalog),
suggestedDefaults: galleryEntry?.recommendedDefaults ?? {
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
@@ -4619,8 +4637,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected");
const sourceTemplateKey =
typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null;
const credentialFields = galleryEntry?.credentialFields ?? [
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : [
{
label: "App key",
configPath: "credentials.authorization",
@@ -4784,7 +4802,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId);
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null;
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
const endpoints = await oauthEndpointsForConnection(connection, null, input.redirectUri);
const client = oauthClientForConnection(connection, endpoints.provider);
if (!client.clientId) throw unprocessable(`OAuth client id is not configured for ${endpoints.provider}`);
@@ -4884,7 +4902,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
connection: refresh.connection,
catalog: refresh.catalog,
actions: groupedActions(refresh.catalog),
suggestedDefaults: galleryEntry?.recommendedDefaults ?? {
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
},
+2 -2
View File
@@ -34,7 +34,7 @@ import type {
ToolRiskLevel,
UpdateToolPolicy,
ReorderToolPolicies,
AppGalleryEntry,
AppDefinition,
ToolAppsAttentionResponse,
ToolConnectionActivityResponse,
ToolConnectionTestAgentsResponse,
@@ -70,7 +70,7 @@ export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary;
export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] };
export type ToolPoliciesResponse = { policies: ToolPolicy[] };
export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
export type ToolGalleryResponse = { apps: AppGalleryEntry[] };
export type ToolGalleryResponse = { apps: AppDefinition[] };
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
expiresAt?: string | Date | null;
+6 -6
View File
@@ -1,7 +1,7 @@
import { ChevronLeft } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import type { AppGalleryEntry, ToolApplication, ToolConnection } from "@paperclipai/shared";
import type { AppDefinition, ToolApplication, ToolConnection } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { toolsApi } from "@/api/tools";
import { useCompany } from "@/context/CompanyContext";
@@ -83,7 +83,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={logoEntry?.branding.logoUrl} size={28} />
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
</div>
</div>
@@ -117,17 +117,17 @@ function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string {
}
function galleryEntryFor(
apps: AppGalleryEntry[],
apps: AppDefinition[],
connection: ToolConnection | undefined,
application: ToolApplication | undefined,
): AppGalleryEntry | null {
): AppDefinition | null {
if (application?.applicationKey) {
const keyed = apps.find((app) => app.key === application.applicationKey);
const keyed = apps.find((app) => app.slug === 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) => app.name.toLowerCase() === name) ?? apps.find((app) => app.slug === name) ?? null;
}
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
+5 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Pencil } from "lucide-react";
import type {
AppGalleryEntry,
AppDefinition,
ToolConnection,
ToolPolicy,
ToolProfileWithDetails,
@@ -479,7 +479,7 @@ function AppDetailHeader({
}: {
appName: string;
connection: ToolConnection;
logoEntry: AppGalleryEntry | null;
logoEntry: AppDefinition | null;
status: StatusInfo;
actionCount: number;
renaming: boolean;
@@ -493,7 +493,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={logoEntry?.branding.logoUrl} size={44} />
<div>
{renaming ? (
<form
@@ -607,10 +607,10 @@ function accessFrom(profile: ToolProfileWithDetails | undefined): AccessDraft {
return { mode: "specific", agentIds };
}
function galleryEntryFor(apps: AppGalleryEntry[], connection: ToolConnection | undefined): AppGalleryEntry | null {
function galleryEntryFor(apps: AppDefinition[], connection: ToolConnection | undefined): AppDefinition | 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((a) => a.name.toLowerCase() === name) ?? apps.find((a) => a.slug === name) ?? null;
}
function addAll(set: Set<string>, ids: string[]): Set<string> {
+2 -2
View File
@@ -123,8 +123,8 @@ export function AppNotConnected() {
const gallery = galleryQuery.data?.apps ?? [];
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 ? gallery.find((entry) => entry.slug === application.applicationKey)?.branding.logoUrl : undefined) ??
gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.branding.logoUrl;
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
const connectHref = reconnectHref({
+10 -58
View File
@@ -3,6 +3,7 @@
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppsConnect } from "./AppsConnect";
@@ -15,6 +16,9 @@ const mockNavigate = vi.hoisted(() => vi.fn());
const mockSearch = vi.hoisted(() => ({ value: "" }));
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
vi.mock("@/api/tools", () => ({
toolsApi: {
listGallery: (companyId: string) => listGalleryMock(companyId),
@@ -122,15 +126,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
document.body.appendChild(container);
listGalleryMock.mockResolvedValue({
apps: [
{
key: "zapier",
name: "Zapier",
tagline: "Automate things",
authKind: "api_key",
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
logoUrl: null,
credentialFields: [{ configPath: "credentials.authorization", label: "API key", required: true }],
},
ZAPIER,
],
});
finishAppMock.mockResolvedValue({});
@@ -280,15 +276,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockSearch.value = "byo=1&source=zapier";
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "zapier",
name: "Zapier",
tagline: "Automate things",
authKind: "api_key",
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
logoUrl: "https://example.com/zapier.png",
credentialFields: [],
},
{ ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } },
],
});
connectAppMock.mockResolvedValueOnce({
@@ -475,16 +463,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("shows the Google Sheets robot email and keeps empty sheet links from continuing", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
],
});
await render();
@@ -505,16 +484,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("shows inline validation for invalid Google Sheets links", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
],
});
await render();
@@ -615,16 +585,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("a custom name on the Google Sheets step is sent to the connect mutation", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
],
});
await render();
@@ -659,16 +620,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("passes parsed Google Sheets IDs as connection config values", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
],
});
await render();
+36 -31
View File
@@ -15,11 +15,11 @@ import {
import type { LucideIcon } from "lucide-react";
import type {
Agent,
AppGalleryEntry,
AppDefinition,
ConnectToolAppResult,
ToolAppConnectionActionSummary,
} from "@paperclipai/shared";
import { getToolAppGalleryEntryForUrl } from "@paperclipai/shared";
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod } from "@paperclipai/shared";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
@@ -83,8 +83,8 @@ function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"];
}
function isGoogleSheetsEntry(entry: AppGalleryEntry | null): boolean {
return entry?.key === "google-sheets";
function isGoogleSheetsEntry(entry: AppDefinition | null): boolean {
return entry?.slug === "google-sheets";
}
export function AppsConnect() {
@@ -109,7 +109,7 @@ export function AppsConnect() {
});
const [step, setStep] = useState<Step>(appKey || prefill.link || zapierSource ? "key" : "gallery");
const [entry, setEntry] = useState<AppGalleryEntry | null>(null);
const [entry, setEntry] = useState<AppDefinition | null>(null);
const [galleryName, setGalleryName] = useState("");
const [linkUrl, setLinkUrl] = useState(prefill.link);
const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : ""));
@@ -160,15 +160,15 @@ export function AppsConnect() {
useEffect(() => {
if (!appKey || galleryQuery.isLoading || !galleryQuery.data) return;
const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.key === appKey);
if (!requestedEntry || requestedEntry.authKind === "oauth" || requestedEntry.availability?.available === false) {
const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === appKey);
if (!requestedEntry || getAvailableConnectionMethod(requestedEntry)?.auth === "oauth" || requestedEntry.availability?.available === false) {
setEntry(null);
setStep("gallery");
navigate("/apps/connect", { replace: true });
return;
}
if (entry?.key !== requestedEntry.key) {
if (entry?.slug !== requestedEntry.slug) {
setEntry(requestedEntry);
setGalleryName(requestedEntry.name);
setLinkUrl("");
@@ -183,11 +183,11 @@ export function AppsConnect() {
setInstallMode("none");
setInstallAgentIds(new Set());
setStep("key");
}, [appKey, entry?.key, galleryQuery.data, galleryQuery.isLoading, navigate]);
}, [appKey, entry?.slug, galleryQuery.data, galleryQuery.isLoading, navigate]);
const setAppStep = (nextStep: Step) => {
setStep(nextStep);
if (entry) navigate(appConnectHref(entry.key, nextStep));
if (entry) navigate(appConnectHref(entry.slug, nextStep));
};
const connectMutation = useMutation({
@@ -196,7 +196,7 @@ export function AppsConnect() {
const sheetIds = isGoogleSheetsEntry(entry) ? parseGoogleSheetIds(googleSheetsLinks).ids : [];
const trimmedGalleryName = galleryName.trim();
return toolsApi.connectApp(selectedCompanyId!, {
galleryKey: entry.key,
galleryKey: entry.slug,
name: trimmedGalleryName || undefined,
credentialValues: credentials,
configValues: isGoogleSheetsEntry(entry) ? { allowedSpreadsheetIds: sheetIds } : undefined,
@@ -284,7 +284,7 @@ export function AppsConnect() {
entry?.name ??
(linkName.trim() || defaultLinkName(linkUrl) || "this app");
const zapierEntry = zapierSource
? galleryQuery.data?.apps.find((app) => app.key === "zapier") ?? null
? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null
: null;
const stepLabels = zapierSource
? ZAPIER_STEP_LABELS
@@ -311,7 +311,7 @@ export function AppsConnect() {
labels={stepLabels}
appIdentity={
zapierSource
? { name: "Zapier", logoUrl: zapierEntry?.logoUrl ?? null }
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
: undefined
}
onCancel={() => navigate(zapierSource ? "/apps/browse" : "/apps")}
@@ -338,10 +338,10 @@ export function AppsConnect() {
setInstallMode("none");
setInstallAgentIds(new Set());
setStep("key");
navigate(appConnectHref(picked.key, "key"));
navigate(appConnectHref(picked.slug, "key"));
}}
onUseLink={(url) => {
const matchedEntry = getToolAppGalleryEntryForUrl(url, galleryQuery.data?.apps ?? []);
const matchedEntry = getAppDefinitionForUrl(url, galleryQuery.data?.apps ?? []);
setEntry(null);
setGalleryName("");
setLinkUrl(url);
@@ -470,7 +470,7 @@ export function AppsConnect() {
{step === "success" && (
<SuccessStep
appName={appName}
logoUrl={entry?.logoUrl}
logoUrl={entry?.branding.logoUrl}
enabledCount={Object.values(enabled).filter(Boolean).length}
access={access}
installMode={installMode}
@@ -607,11 +607,11 @@ function GalleryStep({
onPasteConfig,
}: {
loading: boolean;
apps: AppGalleryEntry[];
apps: AppDefinition[];
/** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
byo?: boolean;
source?: string | null;
onPick: (entry: AppGalleryEntry) => void;
onPick: (entry: AppDefinition) => void;
onUseLink: (link: string) => void;
onRunYourOwn: () => void;
onPasteConfig: () => void;
@@ -635,7 +635,7 @@ function GalleryStep({
return apps.filter((a) => a.name.toLowerCase().includes(q));
}, [apps, search]);
const normalizedLink = normalizeAppLink(linkInput);
const matchedEntry = normalizedLink ? getToolAppGalleryEntryForUrl(normalizedLink, apps) : null;
const matchedEntry = normalizedLink ? getAppDefinitionForUrl(normalizedLink, apps) : null;
const zapierSource = source === "zapier";
const continueWithLink = () => {
@@ -672,12 +672,12 @@ function GalleryStep({
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{filtered.map((app) => {
const copy = appCopyFor(app.key, app.tagline);
const oauth = app.authKind === "oauth";
const copy = appCopyFor(app.slug, app.description);
const oauth = getAvailableConnectionMethod(app)?.auth === "oauth";
const unavailable = app.availability?.available === false;
return (
<button
key={app.key}
key={app.slug}
type="button"
disabled={oauth || unavailable}
title={
@@ -691,7 +691,7 @@ function GalleryStep({
oauth || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
)}
>
<AppLogo name={app.name} logoUrl={app.logoUrl} size={36} />
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
<div className="mt-3 text-xs font-semibold text-foreground">
@@ -740,7 +740,7 @@ function GalleryStep({
{matchedEntry && (
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-sm">
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.logoUrl} size={24} />
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} size={24} />
<span className="truncate">This looks like {matchedEntry.name}.</span>
</div>
<Button
@@ -750,7 +750,7 @@ function GalleryStep({
disabled={matchedEntry.availability?.available === false}
onClick={() => {
setLinkError(null);
if (matchedEntry.key === "zapier") {
if (matchedEntry.slug === "zapier") {
continueWithLink();
return;
}
@@ -759,7 +759,7 @@ function GalleryStep({
>
{matchedEntry.availability?.available === false
? "Not available"
: matchedEntry.key === "zapier"
: matchedEntry.slug === "zapier"
? "Continue"
: `Use ${matchedEntry.name}`}
</Button>
@@ -1037,7 +1037,7 @@ function KeyStep({
onBack,
onConnect,
}: {
entry: AppGalleryEntry;
entry: AppDefinition;
name: string;
onNameChange: (next: string) => void;
values: Record<string, string>;
@@ -1049,8 +1049,13 @@ function KeyStep({
onBack: () => void;
onConnect: () => void;
}) {
const copy = appCopyFor(entry.key, entry.tagline);
const fields = entry.credentialFields ?? [];
const copy = appCopyFor(entry.slug, entry.description);
const method = getAvailableConnectionMethod(entry);
const fields = (method?.credentialFields ?? []).map((field) => ({
...field,
configPath: credentialConfigPath(field),
helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "",
}));
const allFilled = fields.every(
(f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0,
);
@@ -1063,7 +1068,7 @@ function KeyStep({
return (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-center gap-3">
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={48} />
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={48} />
<div>
<h2 className="text-lg font-bold tracking-tight sm:text-xl">Connect Google Sheets</h2>
<p className="text-sm text-muted-foreground">{copy.short}</p>
@@ -1136,7 +1141,7 @@ function KeyStep({
return (
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-center gap-3">
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={48} />
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={48} />
<div>
<h2 className="text-xl font-bold tracking-tight">Connect {entry.name}</h2>
<p className="text-sm text-muted-foreground">{copy.short}</p>
+12 -12
View File
@@ -1,7 +1,7 @@
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 type { AppDefinition } from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
@@ -49,8 +49,8 @@ export function Browse() {
const gallery = galleryQuery.data?.apps ?? [];
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) => entry.slug === key)).filter(
(entry): entry is AppDefinition => Boolean(entry),
),
[gallery],
);
@@ -61,7 +61,7 @@ export function Browse() {
return gallery.filter(
(entry) =>
entry.name.toLowerCase().includes(trimmed) ||
entry.tagline.toLowerCase().includes(trimmed) ||
entry.description.toLowerCase().includes(trimmed) ||
(entry.description?.toLowerCase().includes(trimmed) ?? false),
);
}, [gallery, trimmed]);
@@ -109,9 +109,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={entry.slug}
entry={entry}
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
onConnect={entry.slug === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
compact
/>
))}
@@ -132,9 +132,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={entry.slug}
entry={entry}
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
onConnect={entry.slug === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
/>
))}
</div>
@@ -160,7 +160,7 @@ function AppTile({
onConnect,
compact = false,
}: {
entry: AppGalleryEntry;
entry: AppDefinition;
onConnect?: () => void;
compact?: boolean;
}) {
@@ -175,7 +175,7 @@ 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} />
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={36} />
<span className="text-xs font-medium text-foreground">{entry.name}</span>
<span className={disabled ? "text-xs text-muted-foreground" : "text-xs font-semibold text-primary"}>
{disabled ? "Coming soon" : "Connect →"}
@@ -192,10 +192,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={entry.name} logoUrl={entry.branding.logoUrl} 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="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{entry.description}</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 →"}
+5 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { AppWindow, ShieldAlert, ShieldQuestion } from "lucide-react";
import type {
AppGalleryEntry,
AppDefinition,
ToolApplication,
ToolConnection,
ToolProfileWithDetails,
@@ -115,13 +115,13 @@ export function Connections() {
const gallery = galleryQuery.data?.apps ?? [];
const logoByName = useMemo(() => {
const map = new Map<string, AppGalleryEntry>();
const map = new Map<string, AppDefinition>();
for (const entry of gallery) map.set(entry.name.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, AppDefinition>();
for (const entry of gallery) map.set(entry.slug, entry);
return map;
}, [gallery]);
@@ -173,7 +173,7 @@ export function Connections() {
status: statusFor(application, appConnections),
actionCount,
lastUsedAt,
logoUrl: galleryEntry?.logoUrl ?? logoByName.get(application.name.toLowerCase())?.logoUrl,
logoUrl: galleryEntry?.branding.logoUrl ?? logoByName.get(application.name.toLowerCase())?.branding.logoUrl,
};
});
}, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]);
+11 -6
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ArrowUpRight, Loader2, Lock } from "lucide-react";
import type { AppGalleryEntry, ToolConnection } from "@paperclipai/shared";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import type { AppDefinition, ToolConnection } from "@paperclipai/shared";
import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -37,7 +37,7 @@ function KeySection({
onReplaced,
}: {
connection: ToolConnection;
galleryEntry: AppGalleryEntry | null;
galleryEntry: AppDefinition | null;
onReplaced: () => void;
}) {
const [open, setOpen] = useState(false);
@@ -82,7 +82,7 @@ export function ReconnectCard({
onReconnected,
}: {
connection: ToolConnection;
galleryEntry: AppGalleryEntry | null;
galleryEntry: AppDefinition | null;
onReconnected: () => void;
}) {
return (
@@ -105,12 +105,17 @@ 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 ? 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;
+2 -2
View File
@@ -26,7 +26,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 +35,7 @@ export function SetupPanel({
{description && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
)}
{galleryEntry?.key === "google-sheets" && (
{galleryEntry?.slug === "google-sheets" && (
<GoogleSheetsAllowlistSection
connection={connection}
disabled={configUpdateDisabled}
+2 -2
View File
@@ -1,6 +1,6 @@
import type {
Agent,
AppGalleryEntry,
AppDefinition,
ToolCallEvent,
ToolConnectionActivityResponse,
ToolConnectionLifecycleEvent,
@@ -14,7 +14,7 @@ export interface AppDetailSectionProps {
connectionId: string;
connection: ToolConnection;
appName: string;
galleryEntry: AppGalleryEntry | null;
galleryEntry: AppDefinition | null;
catalog: ToolCatalogEntry[];
active: ToolCatalogEntry[];
readOnly: ToolCatalogEntry[];
@@ -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({