mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(apps): add governed PostHog connector
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
de9645ab73
commit
b5301d3ab3
22 files changed
+890
-69
No files matched your search
@@ -89,7 +89,7 @@ PagerDuty.
|
||||
| 12 | PagerDuty | E | Direct MCP with REST wrapper for events | OAuth | Account, service, escalation policy, incident urgency filters | Read incidents/on-call, ack/resolve with approval | Incident webhooks | S3 |
|
||||
| 13 | Cloudflare | E | Direct MCP with vendor-deep wrapper for Workers/DNS | OAuth or scoped API token | Account, zone, Worker/project filters | Read zones/deployments/logs, draft DNS/Worker change | Audit/deployment sync optional | S4 |
|
||||
| 14 | Vercel | A | Direct MCP or thin REST wrapper | Vercel OAuth | Team, project, environment, deployment filters | Read projects/deployments/log metadata, redeploy/cancel gated | Deployment webhooks | S3 |
|
||||
| 15 | PostHog | C | Direct MCP/API-key provider | Project/personal API key secret ref | Project, environment, dashboard/feature flag filters | Query events/insights/flags, create annotation | Optional insight/flag sync | S2 |
|
||||
| 15 | PostHog | C | Hosted MCP OAuth or personal API key | OAuth token or personal API key secret ref | Project, read-only, feature-group, and tool filters | Query insights/errors/flags/experiments; governed writes | Optional insight/flag sync | S3 |
|
||||
| 16 | Datadog | C | OpenAPI-to-MCP shim first, deep wrapper later | API key + app key secret refs | Site, org, service, monitor, dashboard filters | Read metrics/logs/monitors, mute/unmute gated | Monitor/webhook events | S3 |
|
||||
| 17 | Figma | C | Vendor-deep wrapper; MCP for Dev Mode reads | Figma OAuth | Team, project, file, branch filters | Read files/comments/dev data, create comment | File/comment webhooks optional | S3 |
|
||||
| 18 | Canva | C | Direct app/MCP where available; Connect API wrapper | Canva OAuth | Team, folder, brand/template filters | Search/read designs, create design from template | Asset sync optional | S2 |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# PostHog connection
|
||||
|
||||
Paperclip connects to PostHog's hosted MCP service at
|
||||
`https://mcp.posthog.com/mcp`. The connection supports two explicit methods:
|
||||
|
||||
- browser OAuth, which is recommended for hosted PostHog accounts; or
|
||||
- a PostHog personal API key stored as a Paperclip secret and sent as an
|
||||
`Authorization: Bearer ...` header.
|
||||
|
||||
Paperclip does not silently fall back from OAuth to an API key. The selected
|
||||
method is saved on the connection and reused for reconnects.
|
||||
|
||||
## Service involvement
|
||||
|
||||
PostHog hosts both the MCP resource and OAuth authorization service. Paperclip
|
||||
discovers the OAuth endpoints, dynamically registers the client when needed,
|
||||
stores returned credentials as secret references, and handles the callback at
|
||||
`/api/tools/oauth/callback`. No Paperclip-operated vendor relay is involved.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor A as Administrator
|
||||
participant P as Paperclip
|
||||
participant M as mcp.posthog.com
|
||||
participant O as oauth.posthog.com
|
||||
|
||||
A->>P: Choose PostHog sign-in and project filters
|
||||
P->>M: Discover protected-resource metadata
|
||||
M-->>P: Authorization server metadata URL
|
||||
P->>O: Discover endpoints and register OAuth client
|
||||
O-->>P: Client registration
|
||||
P-->>A: Open browser authorization
|
||||
A->>O: Approve access
|
||||
O-->>P: Redirect to /api/tools/oauth/callback
|
||||
P->>O: Exchange authorization code
|
||||
O-->>P: Access and refresh tokens
|
||||
P->>M: tools/list with project and safety filters
|
||||
M-->>P: Filtered PostHog tool catalog
|
||||
```
|
||||
|
||||
The current hosted endpoints are:
|
||||
|
||||
| Purpose | Endpoint |
|
||||
| --- | --- |
|
||||
| MCP resource | `https://mcp.posthog.com/mcp` |
|
||||
| Protected-resource metadata | `https://mcp.posthog.com/.well-known/oauth-protected-resource/mcp` |
|
||||
| Authorization-server metadata | `https://oauth.posthog.com/.well-known/oauth-authorization-server` |
|
||||
| Authorize | `https://oauth.posthog.com/oauth/authorize/` |
|
||||
| Token | `https://oauth.posthog.com/oauth/token/` |
|
||||
| Dynamic client registration | `https://oauth.posthog.com/oauth/register/` |
|
||||
| Revoke | `https://oauth.posthog.com/oauth/revoke/` |
|
||||
| Paperclip callback | `/api/tools/oauth/callback` |
|
||||
|
||||
Redirect-URI constraints and token lifetimes remain provider-controlled and
|
||||
must be rechecked during credentialed QA; Paperclip does not encode guessed
|
||||
values for either.
|
||||
|
||||
## Administrator setup
|
||||
|
||||
1. In **Apps → Browse**, choose **PostHog**.
|
||||
2. Explicitly choose **Sign in with PostHog** or **Use a personal API key**.
|
||||
3. Enter the numeric PostHog project ID.
|
||||
4. Keep **Read-only mode** enabled unless agents have an approved need to make
|
||||
changes.
|
||||
5. Limit the catalog with one or both of **Feature groups** and **Individual
|
||||
tools**. Paperclip currently fixes response mode to individual tools; CLI
|
||||
mode is unavailable until nested execution is governed.
|
||||
6. For OAuth, continue through browser consent. For API-key setup, create a
|
||||
personal API key using PostHog's **MCP Server** preset and paste it into
|
||||
Paperclip. Never put the key in connection configuration or a URL.
|
||||
7. Review discovered actions. Known writes ask first, destructive or nested
|
||||
execution tools remain quarantined, and unknown PostHog tools default to
|
||||
write risk until reviewed.
|
||||
|
||||
The safe configuration is projected to PostHog as `project_id`, `readonly`,
|
||||
`features`, `tools`, and `mode` query parameters. PostHog documents these
|
||||
options in its [MCP overview](https://posthog.com/docs/model-context-protocol)
|
||||
and [MCP FAQ](https://posthog.com/docs/model-context-protocol/faq).
|
||||
|
||||
PostHog does not charge for MCP requests themselves, but the actions they
|
||||
perform can consume normal PostHog usage or AI credits.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Audience: internal engineers and product contributors working on integrations.
|
||||
|
||||
Provider notes: [PostHog](./POSTHOG.md).
|
||||
|
||||
Post-read action: classify a new integration request, pick the right Paperclip
|
||||
layer to change, and avoid creating a parallel connection framework.
|
||||
|
||||
|
||||
@@ -2,13 +2,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 a4 from "./app-definitions/posthog.json" with { type: "json" };
|
||||
import a5 from "./app-definitions/linear.json" with { type: "json" };
|
||||
import a6 from "./app-definitions/google-sheets.json" with { type: "json" };
|
||||
import a7 from "./app-definitions/context7.json" with { type: "json" };
|
||||
import a8 from "./app-definitions/oauth-generic.json" with { type: "json" };
|
||||
import a9 from "./app-definitions/api-key-generic.json" with { type: "json" };
|
||||
import a10 from "./app-definitions/sentry.json" with { type: "json" };
|
||||
import a11 from "./app-definitions/vercel.json" with { type: "json" };
|
||||
import a12 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[];
|
||||
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12] as AppDefinition[];
|
||||
@@ -3,12 +3,13 @@ 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("contains thirteen reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","posthog","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"]));
|
||||
it("uses discovery-first Notion MCP OAuth metadata",()=>{
|
||||
const notion=APP_DEFINITIONS.find((app)=>app.slug==="notion");
|
||||
expect(notion?.redirectConstraints).toBe("https-or-loopback-http");
|
||||
expect(notion?.methods[0]?.defaults).toEqual({serverUrl:"https://mcp.notion.com/mcp"});
|
||||
});
|
||||
it("preserves required Linear OAuth scopes",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="linear")?.methods[0]?.defaults?.scopesHint).toEqual(["read","write"]));
|
||||
it("offers PostHog OAuth and API-key methods with pinned safe defaults",()=>{const posthog=APP_DEFINITIONS.find((app)=>app.slug==="posthog");expect(posthog?.methods.map((method)=>method.key)).toEqual(["mcp-oauth","mcp-api-key"]);for(const method of posthog?.methods??[]){expect(method.riskTier).toBe("S3");expect(method.tenantFields?.find((field)=>field.key==="readOnly")?.defaultValue).toBe(true);expect(method.tenantFields?.find((field)=>field.key==="projectId")?.transport?.name).toBe("project_id");expect(method.configRequirements?.atLeastOneOf).toEqual(["features","tools"])}});
|
||||
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()}});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const CONNECTABLE_APP_SLUGS = new Set([
|
||||
"github",
|
||||
"slack",
|
||||
"notion",
|
||||
"posthog",
|
||||
"linear",
|
||||
"google-sheets",
|
||||
"context7",
|
||||
@@ -47,19 +48,27 @@ export function getAppDefinitionForUrl(
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function getAvailableConnectionMethod(app: AppDefinition): ConnectionMethodDef | null {
|
||||
export function getAvailableConnectionMethods(app: AppDefinition): ConnectionMethodDef[] {
|
||||
const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY;
|
||||
return app.methods.find((method) =>
|
||||
return app.methods.filter((method) =>
|
||||
method.ownershipModes.some((ownership) => availability[ownership] !== false)
|
||||
) ?? null;
|
||||
);
|
||||
}
|
||||
|
||||
export function getAvailableConnectionMethod(
|
||||
app: AppDefinition,
|
||||
methodKey?: string | null,
|
||||
): ConnectionMethodDef | null {
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
return methodKey ? methods.find((method) => method.key === methodKey) ?? null : methods[0] ?? null;
|
||||
}
|
||||
|
||||
export function credentialConfigPath(field: FieldDef): string {
|
||||
return `credentials.${field.key}`;
|
||||
}
|
||||
|
||||
export function recommendedDefaultsForApp(app: AppDefinition): Record<string, unknown> {
|
||||
const method = getAvailableConnectionMethod(app);
|
||||
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
|
||||
const method = getAvailableConnectionMethod(app, methodKey);
|
||||
return {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"],
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "posthog",
|
||||
"name": "PostHog",
|
||||
"description": "Analyze product usage, errors, feature flags, and experiments in a pinned PostHog project.",
|
||||
"categories": [
|
||||
"analytics"
|
||||
],
|
||||
"featured": true,
|
||||
"branding": {
|
||||
"logoUrl": "https://www.google.com/s2/favicons?domain=posthog.com&sz=128"
|
||||
},
|
||||
"urlPatterns": [
|
||||
"https://mcp.posthog.com/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "mcp-oauth",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "oauth",
|
||||
"ownershipModes": [
|
||||
"customer",
|
||||
"dcr"
|
||||
],
|
||||
"whenToUse": "Sign in with PostHog in the browser. Recommended for hosted PostHog accounts.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.posthog.com/mcp"
|
||||
},
|
||||
"guidanceMd": "Pin the connection to one PostHog project, keep read-only mode enabled by default, and expose only the feature groups or tools agents need.",
|
||||
"riskTier": "S3",
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"label": "Project ID",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "12345",
|
||||
"helperMd": "Find the numeric project ID in PostHog project settings.",
|
||||
"validation": {
|
||||
"pattern": "^[0-9]+$",
|
||||
"maxLength": 32
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "project_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "readOnly",
|
||||
"label": "Read-only mode",
|
||||
"type": "checkbox",
|
||||
"defaultValue": true,
|
||||
"helperMd": "Keep enabled unless agents explicitly need to change PostHog data.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "readonly",
|
||||
"format": "boolean",
|
||||
"omitFalse": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "features",
|
||||
"label": "Feature groups",
|
||||
"type": "textarea",
|
||||
"placeholder": "insights, error_tracking, flags, experiments",
|
||||
"defaultValue": "insights,error_tracking,flags,experiments",
|
||||
"helperMd": "Comma-separated PostHog feature groups to expose.",
|
||||
"validation": {
|
||||
"maxLength": 500
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "features",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "tools",
|
||||
"label": "Individual tools",
|
||||
"type": "textarea",
|
||||
"placeholder": "Optional comma-separated tool names",
|
||||
"helperMd": "Optional exact tool names. These are combined with feature groups.",
|
||||
"validation": {
|
||||
"maxLength": 2000
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "tools",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mode",
|
||||
"label": "Tool response mode",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"placeholder": "Individual tools",
|
||||
"defaultValue": "tools",
|
||||
"options": [
|
||||
{
|
||||
"value": "tools",
|
||||
"label": "Individual tools"
|
||||
}
|
||||
],
|
||||
"helperMd": "CLI mode remains unavailable until nested execution is governed.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "mode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"configRequirements": {
|
||||
"atLeastOneOf": [
|
||||
"features",
|
||||
"tools"
|
||||
]
|
||||
},
|
||||
"requiredResourceFilters": [
|
||||
"project",
|
||||
"read_only",
|
||||
"features",
|
||||
"tools",
|
||||
"mode"
|
||||
],
|
||||
"label": "Sign in with PostHog",
|
||||
"consoleLinks": {
|
||||
"docs": "https://posthog.com/docs/model-context-protocol"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-api-key",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use a PostHog personal API key when browser sign-in is not suitable.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.posthog.com/mcp"
|
||||
},
|
||||
"guidanceMd": "Pin the connection to one PostHog project, keep read-only mode enabled by default, and expose only the feature groups or tools agents need.",
|
||||
"riskTier": "S3",
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"label": "Project ID",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "12345",
|
||||
"helperMd": "Find the numeric project ID in PostHog project settings.",
|
||||
"validation": {
|
||||
"pattern": "^[0-9]+$",
|
||||
"maxLength": 32
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "project_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "readOnly",
|
||||
"label": "Read-only mode",
|
||||
"type": "checkbox",
|
||||
"defaultValue": true,
|
||||
"helperMd": "Keep enabled unless agents explicitly need to change PostHog data.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "readonly",
|
||||
"format": "boolean",
|
||||
"omitFalse": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "features",
|
||||
"label": "Feature groups",
|
||||
"type": "textarea",
|
||||
"placeholder": "insights, error_tracking, flags, experiments",
|
||||
"defaultValue": "insights,error_tracking,flags,experiments",
|
||||
"helperMd": "Comma-separated PostHog feature groups to expose.",
|
||||
"validation": {
|
||||
"maxLength": 500
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "features",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "tools",
|
||||
"label": "Individual tools",
|
||||
"type": "textarea",
|
||||
"placeholder": "Optional comma-separated tool names",
|
||||
"helperMd": "Optional exact tool names. These are combined with feature groups.",
|
||||
"validation": {
|
||||
"maxLength": 2000
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "tools",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mode",
|
||||
"label": "Tool response mode",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"placeholder": "Individual tools",
|
||||
"defaultValue": "tools",
|
||||
"options": [
|
||||
{
|
||||
"value": "tools",
|
||||
"label": "Individual tools"
|
||||
}
|
||||
],
|
||||
"helperMd": "CLI mode remains unavailable until nested execution is governed.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "mode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"configRequirements": {
|
||||
"atLeastOneOf": [
|
||||
"features",
|
||||
"tools"
|
||||
]
|
||||
},
|
||||
"requiredResourceFilters": [
|
||||
"project",
|
||||
"read_only",
|
||||
"features",
|
||||
"tools",
|
||||
"mode"
|
||||
],
|
||||
"label": "Use a personal API key",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "PostHog personal API key",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "phx_...",
|
||||
"secret": true
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"consoleLinks": {
|
||||
"keys": "https://posthog.com/docs/model-context-protocol/faq",
|
||||
"docs": "https://posthog.com/docs/model-context-protocol/faq"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -239,6 +239,7 @@ export {
|
||||
credentialConfigPath,
|
||||
getAppDefinitionForUrl,
|
||||
getAvailableConnectionMethod,
|
||||
getAvailableConnectionMethods,
|
||||
getConnectableAppDefinition,
|
||||
recommendedDefaultsForApp,
|
||||
} from "./app-definitions.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ToolConnectionOwnership, ToolConnectionTransport } from "./tool-access.js";
|
||||
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
|
||||
export type OAuthRedirectConstraints = "https-or-loopback-http";
|
||||
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; 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 FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
|
||||
export interface ConnectionMethodDef { key:string; label?: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[]; configRequirements?:{atLeastOneOf?:string[]}; 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; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
|
||||
@@ -1,6 +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"]})});
|
||||
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(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]})});
|
||||
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),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(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),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"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]})});
|
||||
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(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
|
||||
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
connectionTokenRequestSchema,
|
||||
connectToolAppSchema,
|
||||
createToolConnectionSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
toolCredentialSecretRefSchema,
|
||||
@@ -48,6 +49,18 @@ describe("tool access validators", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps app method configuration separate from secrets", () => {
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
configValues: { projectId: "12345", readOnly: true, features: "insights" },
|
||||
}).success).toBe(true);
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
configValues: { projectId: "12345", apiKey: "phx_raw" },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts secret references for connection credentials", () => {
|
||||
const parsed = createToolConnectionSchema.safeParse({
|
||||
applicationId: "11111111-1111-4111-8111-111111111111",
|
||||
|
||||
@@ -260,11 +260,14 @@ export type DisableToolStdioCommandTemplate = z.infer<typeof disableToolStdioCom
|
||||
|
||||
export const connectToolAppSchema = z.object({
|
||||
galleryKey: z.string().trim().min(1).max(120).optional(),
|
||||
connectionMethodKey: z.string().trim().min(1).max(120).optional(),
|
||||
link: z.string().trim().url().max(2000).optional(),
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)).optional(),
|
||||
configValues: z.record(z.string().trim().min(1).max(200), z.unknown()).optional(),
|
||||
applicationId: z.string().uuid().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]);
|
||||
}).refine(
|
||||
(value) => Boolean(value.galleryKey) !== Boolean(value.link),
|
||||
{ message: "Provide exactly one of galleryKey or link" },
|
||||
|
||||
@@ -3,11 +3,20 @@ const root=process.cwd(); const corpus=process.env.PAPERCLIP_CONTENT_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 posthogConfigFields=()=>[
|
||||
{key:"projectId",label:"Project ID",type:"text",required:true,placeholder:"12345",helperMd:"Find the numeric project ID in PostHog project settings.",validation:{pattern:"^[0-9]+$",maxLength:32},transport:{location:"query",name:"project_id"}},
|
||||
{key:"readOnly",label:"Read-only mode",type:"checkbox",defaultValue:true,helperMd:"Keep enabled unless agents explicitly need to change PostHog data.",transport:{location:"query",name:"readonly",format:"boolean",omitFalse:true}},
|
||||
{key:"features",label:"Feature groups",type:"textarea",placeholder:"insights, error_tracking, flags, experiments",defaultValue:"insights,error_tracking,flags,experiments",helperMd:"Comma-separated PostHog feature groups to expose.",validation:{maxLength:500},transport:{location:"query",name:"features",format:"csv"}},
|
||||
{key:"tools",label:"Individual tools",type:"textarea",placeholder:"Optional comma-separated tool names",helperMd:"Optional exact tool names. These are combined with feature groups.",validation:{maxLength:2000},transport:{location:"query",name:"tools",format:"csv"}},
|
||||
{key:"mode",label:"Tool response mode",type:"select",required:true,placeholder:"Individual tools",defaultValue:"tools",options:[{value:"tools",label:"Individual tools"}],helperMd:"CLI mode remains unavailable until nested execution is governed.",transport:{location:"query",name:"mode"}},
|
||||
];
|
||||
const posthogMethod=(key,auth,extra={})=>method(key,"mcp_remote",auth,{serverUrl:"https://mcp.posthog.com/mcp"},"S3","Pin the connection to one PostHog project, keep read-only mode enabled by default, and expose only the feature groups or tools agents need.",{tenantFields:posthogConfigFields(),configRequirements:{atLeastOneOf:["features","tools"]},requiredResourceFilters:["project","read_only","features","tools","mode"],...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"},"S3","Connect Notion for workspace content. Share only the pages and databases agents should use.",{requiredResourceFilters:["workspace","page","database"]}),{redirectConstraints:"https-or-loopback-http"}],
|
||||
["posthog","PostHog","Analyze product usage, errors, feature flags, and experiments in a pinned PostHog project.","analytics","posthog.com",["https://mcp.posthog.com/*"],[posthogMethod("mcp-oauth","oauth",{label:"Sign in with PostHog",ownershipModes:["customer","dcr"],whenToUse:"Sign in with PostHog in the browser. Recommended for hosted PostHog accounts.",consoleLinks:{docs:"https://posthog.com/docs/model-context-protocol"}}),posthogMethod("mcp-api-key","api_key",{label:"Use a personal API key",whenToUse:"Use a PostHog personal API key when browser sign-in is not suitable.",credentialFields:[field("authorization","PostHog personal API key","phx_...")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "},consoleLinks:{keys:"https://posthog.com/docs/model-context-protocol/faq",docs:"https://posthog.com/docs/model-context-protocol/faq"}})],{featured:true}],
|
||||
["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.")],
|
||||
@@ -16,7 +25,7 @@ const apps=[
|
||||
["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,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:[m],...extra}));
|
||||
].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","posthog","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:Array.isArray(m)?m:[m],...extra}));
|
||||
const parseTableRow=(line)=>line.slice(1,-1).split("|").map((cell)=>cell.trim());
|
||||
const parseCapture=(fileName)=>{
|
||||
const markdown=fs.readFileSync(path.join(corpus,fileName),"utf8");
|
||||
|
||||
@@ -37,11 +37,12 @@ import {
|
||||
toolStdioCommandTemplates,
|
||||
} from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getConnectableAppDefinition } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { classifyRisk, toolAccessService } from "../services/tool-access.js";
|
||||
import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from "../services/tool-access.js";
|
||||
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js";
|
||||
@@ -2661,6 +2662,62 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires an explicit PostHog method and projects validated project filters", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
|
||||
await expect(service.connectGalleryApp(company.id, {
|
||||
galleryKey: "posthog",
|
||||
configValues: { projectId: "12345", features: "insights" },
|
||||
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 400 });
|
||||
|
||||
const fetchMock = mockToolsList([
|
||||
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
||||
{ name: "delete_feature_flag" },
|
||||
{ name: "brand_new_tool" },
|
||||
]);
|
||||
const result = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
credentialValues: { "credentials.authorization": "phx_test-secret" },
|
||||
configValues: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights, error_tracking\ninsights",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
},
|
||||
}, { actorType: "user", actorId: "board" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://mcp.posthog.com/mcp?project_id=12345&readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer phx_test-secret" }),
|
||||
}),
|
||||
);
|
||||
expect(result.connection).toMatchObject({
|
||||
authKind: "api_key",
|
||||
config: {
|
||||
sourceTemplateKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
methodConfig: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights,error_tracking",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
},
|
||||
safeDefault: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.connection.config)).not.toContain("phx_test-secret");
|
||||
expect(result.catalog).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ toolName: "query_insight", riskLevel: "read", status: "active" }),
|
||||
expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "quarantined" }),
|
||||
expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "quarantined" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("stores approved class-3 credential refs on thin tool connections", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
@@ -7092,4 +7149,43 @@ describe("classifyRisk", () => {
|
||||
expect(notionRisk("notion-delete-page")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "move_pages" })).toBe("read");
|
||||
});
|
||||
|
||||
it("uses conservative PostHog defaults for unknown and nested-execution tools", () => {
|
||||
expect(classifyRisk({ name: "query_insight", annotations: { readOnlyHint: true } }, "posthog")).toBe("read");
|
||||
expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write");
|
||||
expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeConnectionMethodConfig", () => {
|
||||
const posthog = getConnectableAppDefinition("posthog")!;
|
||||
const apiKeyMethod = posthog.methods.find((method) => method.key === "mcp-api-key")!;
|
||||
|
||||
it("normalizes and projects PostHog scope without accepting arbitrary config", () => {
|
||||
expect(normalizeConnectionMethodConfig(apiKeyMethod, {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights, error_tracking\ninsights",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
})).toEqual({
|
||||
values: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights,error_tracking",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
},
|
||||
url: "https://mcp.posthog.com/mcp?project_id=12345&readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
|
||||
});
|
||||
expect(() => normalizeConnectionMethodConfig(apiKeyMethod, {
|
||||
projectId: "not-a-project",
|
||||
features: "insights",
|
||||
})).toThrow("Project ID has an invalid value");
|
||||
expect(() => normalizeConnectionMethodConfig(apiKeyMethod, {
|
||||
projectId: "12345",
|
||||
features: "insights",
|
||||
apiKey: "must-not-be-config",
|
||||
})).toThrow("Unknown connection setting: apiKey");
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AppDefinition,
|
||||
ConnectionMethodDef,
|
||||
ConnectionTokenIssuanceOutcome,
|
||||
ConnectionTokenIssuancePath,
|
||||
ConnectionTokenRequest,
|
||||
@@ -107,7 +108,7 @@ import type {
|
||||
UpdateToolProfileWithEntries,
|
||||
UnbindToolProfileBinding,
|
||||
} from "@paperclipai/shared";
|
||||
import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared";
|
||||
import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getAvailableConnectionMethods, 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";
|
||||
@@ -452,14 +453,24 @@ export function googleSheetsRobotEmailFromEnv(
|
||||
return { available: false, reason: "Google Sheets is not available on this instance yet." };
|
||||
}
|
||||
|
||||
function connectionMethodFor(app: AppDefinition) {
|
||||
const method = getAvailableConnectionMethod(app);
|
||||
function connectionMethodFor(app: AppDefinition, methodKey?: string | null) {
|
||||
const method = getAvailableConnectionMethod(app, methodKey);
|
||||
if (!method) throw unprocessable("This app does not have an available connection method");
|
||||
return method;
|
||||
}
|
||||
|
||||
function credentialFieldsFor(app: AppDefinition) {
|
||||
const method = connectionMethodFor(app);
|
||||
function connectionMethodForConnection(
|
||||
app: AppDefinition,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
) {
|
||||
const methodKey = typeof connection.config.connectionMethodKey === "string"
|
||||
? connection.config.connectionMethodKey
|
||||
: null;
|
||||
return connectionMethodFor(app, methodKey);
|
||||
}
|
||||
|
||||
function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) {
|
||||
const method = connectionMethodFor(app, methodKey);
|
||||
return (method.credentialFields ?? []).map((field) => ({
|
||||
label: field.label,
|
||||
configPath: credentialConfigPath(field),
|
||||
@@ -471,6 +482,75 @@ function credentialFieldsFor(app: AppDefinition) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeConnectionMethodConfig(
|
||||
method: ConnectionMethodDef,
|
||||
configValues: Record<string, unknown> | undefined,
|
||||
): { values: Record<string, string | boolean>; url?: string; headers?: Record<string, string> } {
|
||||
const fields = [...(method.tenantFields ?? []), ...(method.extensionFields ?? [])];
|
||||
const allowedKeys = new Set(fields.map((field) => field.key));
|
||||
for (const key of Object.keys(configValues ?? {})) {
|
||||
if (!allowedKeys.has(key)) throw badRequest(`Unknown connection setting: ${key}`);
|
||||
}
|
||||
|
||||
const values: Record<string, string | boolean> = {};
|
||||
for (const field of fields) {
|
||||
const raw = configValues?.[field.key] ?? field.defaultValue;
|
||||
if (field.type === "checkbox") {
|
||||
if (raw !== undefined && typeof raw !== "boolean") throw badRequest(`${field.label} must be true or false`);
|
||||
if (raw !== undefined) values[field.key] = raw;
|
||||
continue;
|
||||
}
|
||||
if (raw !== undefined && typeof raw !== "string") throw badRequest(`${field.label} must be text`);
|
||||
let value = raw?.trim() ?? "";
|
||||
if (field.transport?.format === "csv") {
|
||||
value = Array.from(new Set(value.split(/[\n,]/g).map((entry) => entry.trim()).filter(Boolean))).join(",");
|
||||
}
|
||||
if (field.required && !value) throw badRequest(`Missing connection setting: ${field.label}`);
|
||||
if (!value) continue;
|
||||
if (field.validation?.maxLength && value.length > field.validation.maxLength) {
|
||||
throw badRequest(`${field.label} must be at most ${field.validation.maxLength} characters`);
|
||||
}
|
||||
if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) {
|
||||
throw badRequest(`${field.label} has an invalid value`);
|
||||
}
|
||||
if (field.type === "select" && !field.options?.some((option) => option.value === value)) {
|
||||
throw badRequest(`${field.label} has an invalid option`);
|
||||
}
|
||||
values[field.key] = value;
|
||||
}
|
||||
for (const keys of method.configRequirements?.atLeastOneOf ? [method.configRequirements.atLeastOneOf] : []) {
|
||||
if (!keys.some((key) => typeof values[key] === "string" && values[key].length > 0)) {
|
||||
throw badRequest(`Provide at least one of: ${keys.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = method.defaults?.serverUrl ? new URL(method.defaults.serverUrl) : null;
|
||||
const headers: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const transport = field.transport;
|
||||
const value = values[field.key];
|
||||
if (!transport || value === undefined || (value === false && transport.omitFalse)) continue;
|
||||
const serialized = typeof value === "boolean" ? String(value) : value;
|
||||
if (transport.location === "query") endpoint?.searchParams.set(transport.name, serialized);
|
||||
else headers[transport.name] = serialized;
|
||||
}
|
||||
return {
|
||||
values,
|
||||
...(endpoint ? { url: endpoint.toString() } : {}),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function projectedConnectionHeaders(connection: typeof toolConnections.$inferSelect): Record<string, string> {
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string"
|
||||
? connection.config.sourceTemplateKey
|
||||
: null;
|
||||
const app = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
if (!app) return {};
|
||||
const method = connectionMethodForConnection(app, connection);
|
||||
return normalizeConnectionMethodConfig(method, asRecord(connection.config.methodConfig)).headers ?? {};
|
||||
}
|
||||
|
||||
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) : [];
|
||||
@@ -1273,6 +1353,7 @@ export function classifyRisk(tool: McpToolDescriptor, sourceTemplateKey?: string
|
||||
const annotations = tool.annotations ?? {};
|
||||
if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive";
|
||||
const normalizedToolName = normalizedProviderToolName(tool.name);
|
||||
if (sourceTemplateKey === "posthog" && normalizedToolName === "exec") return "destructive";
|
||||
// Notion's hosted MCP catalog contains mutations whose names do not use one
|
||||
// of the generic create/update/delete verbs (move, duplicate, and convert).
|
||||
// Keep all reviewed tools explicit so provider changes are visible in code,
|
||||
@@ -1282,6 +1363,9 @@ export function classifyRisk(tool: McpToolDescriptor, sourceTemplateKey?: string
|
||||
if (sourceTemplateKey === "notion" && NOTION_READ_TOOLS.has(normalizedToolName)) return "read";
|
||||
if (verbMatches(tool.name, "delete|remove|destroy|unpublish")) return "destructive";
|
||||
if (verbMatches(tool.name, "create|update|write|set|send|publish|post|mutate|mark|archive")) return "write";
|
||||
// PostHog exposes a broad and evolving catalog. Unknown tools must never be
|
||||
// silently treated as reads; provider annotations can opt known reads in.
|
||||
if (sourceTemplateKey === "posthog") return annotations.readOnlyHint === true ? "read" : "write";
|
||||
return "read";
|
||||
}
|
||||
|
||||
@@ -2863,7 +2947,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
}
|
||||
|
||||
async function remoteTools(connection: typeof toolConnections.$inferSelect): Promise<McpToolDescriptor[]> {
|
||||
const headers = await resolveCredentialHeaders(connection);
|
||||
const headers = { ...projectedConnectionHeaders(connection), ...await resolveCredentialHeaders(connection) };
|
||||
const endpoint = await assertRemoteEndpointAllowed(connection.config);
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
@@ -3038,11 +3122,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
const existingByName = new Map(existingRows.map((entry) => [entry.toolName, entry]));
|
||||
const updatedEntries: ToolCatalogEntry[] = [];
|
||||
let quarantinedCount = 0;
|
||||
const quarantineOnRefresh = shouldQuarantineNewEntries(connection) && connection.status === "active";
|
||||
const safeDefault = asRecord(connection.config).safeDefault === true;
|
||||
const sourceTemplateKey = typeof asRecord(connection.config).sourceTemplateKey === "string"
|
||||
? String(asRecord(connection.config).sourceTemplateKey)
|
||||
: null;
|
||||
const quarantineOnRefresh = shouldQuarantineNewEntries(connection)
|
||||
&& (connection.status === "active" || sourceTemplateKey === "posthog");
|
||||
const safeDefault = asRecord(connection.config).safeDefault === true;
|
||||
for (const descriptor of descriptors) {
|
||||
const riskLevel = classifyRisk(descriptor, sourceTemplateKey);
|
||||
const hash = descriptorHash(descriptor, riskLevel);
|
||||
@@ -4043,8 +4128,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function oauthProviderEndpoints(app: AppDefinition): Promise<OAuthProviderEndpoints> {
|
||||
const method = connectionMethodFor(app);
|
||||
async function oauthProviderEndpoints(app: AppDefinition, methodKey?: string | null): Promise<OAuthProviderEndpoints> {
|
||||
const method = connectionMethodFor(app, methodKey);
|
||||
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;
|
||||
@@ -4070,7 +4155,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri);
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
|
||||
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
const galleryMethod = galleryEntry ? connectionMethodFor(galleryEntry) : null;
|
||||
const galleryMethod = galleryEntry ? connectionMethodForConnection(galleryEntry, connection) : null;
|
||||
const hasCompleteGalleryEndpointHints = Boolean(
|
||||
galleryMethod?.defaults?.authorizationEndpoint && galleryMethod.defaults.tokenEndpoint,
|
||||
);
|
||||
@@ -4079,8 +4164,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
: null;
|
||||
const endpoints = smokeLabEndpoints
|
||||
?? discovered
|
||||
?? (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth"
|
||||
? await oauthProviderEndpoints(galleryEntry)
|
||||
?? (galleryEntry && galleryMethod?.auth === "oauth"
|
||||
? await oauthProviderEndpoints(galleryEntry, galleryMethod.key)
|
||||
: await discoverOAuthEndpoints(connection, challenge));
|
||||
if (!endpoints) throw unprocessable("This app connection does not advertise OAuth sign in");
|
||||
assertNotSmokeLabOAuthEndpoints(connection, endpoints);
|
||||
@@ -4091,7 +4176,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
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 = getConnectableAppDefinition(sourceTemplateKey);
|
||||
if (!galleryEntry || connectionMethodFor(galleryEntry).auth !== "oauth") {
|
||||
if (!galleryEntry || connectionMethodForConnection(galleryEntry, connection).auth !== "oauth") {
|
||||
throw unprocessable("This app connection does not use sign in");
|
||||
}
|
||||
return galleryEntry;
|
||||
@@ -4362,7 +4447,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
client: await oauthClientForConnection(input.connection, input.endpoints.provider, input.actor),
|
||||
};
|
||||
}
|
||||
const method = input.galleryEntry ? connectionMethodFor(input.galleryEntry) : null;
|
||||
const method = input.galleryEntry ? connectionMethodForConnection(input.galleryEntry, input.connection) : null;
|
||||
if (!method?.ownershipModes.includes("dcr")) {
|
||||
throw unprocessable(`OAuth client id is not configured for ${input.endpoints.provider}`);
|
||||
}
|
||||
@@ -4839,13 +4924,27 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
}
|
||||
|
||||
const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? "");
|
||||
const method = galleryEntry ? connectionMethodFor(galleryEntry) : null;
|
||||
if (!galleryEntry && input.connectionMethodKey) throw badRequest("Connection method selection requires a gallery app");
|
||||
if (galleryEntry && getAvailableConnectionMethods(galleryEntry).length > 1 && !input.connectionMethodKey) {
|
||||
throw badRequest("Choose a connection method for this app");
|
||||
}
|
||||
const method = galleryEntry ? connectionMethodFor(galleryEntry, input.connectionMethodKey) : null;
|
||||
const transport = method?.transport ?? "mcp_remote";
|
||||
const normalizedMethodConfig = galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY || !method
|
||||
? null
|
||||
: normalizeConnectionMethodConfig(method, input.configValues);
|
||||
const baseConfig = transport === "mcp_remote"
|
||||
? { url: method?.defaults?.serverUrl ?? input.link ?? "" }
|
||||
? { url: normalizedMethodConfig?.url ?? method?.defaults?.serverUrl ?? input.link ?? "" }
|
||||
: { templateId: method?.defaults?.templateKey };
|
||||
let config: Record<string, unknown> = galleryEntry
|
||||
? { ...baseConfig, sourceTemplateKey: galleryEntry.slug, quarantineNewEntries: true }
|
||||
? {
|
||||
...baseConfig,
|
||||
sourceTemplateKey: galleryEntry.slug,
|
||||
connectionMethodKey: method?.key,
|
||||
methodConfig: normalizedMethodConfig?.values ?? {},
|
||||
quarantineNewEntries: true,
|
||||
...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}),
|
||||
}
|
||||
: { ...baseConfig, quarantineNewEntries: true };
|
||||
if (galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY) {
|
||||
const availability = googleSheetsRobotEmailFromEnv();
|
||||
@@ -4877,7 +4976,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = null;
|
||||
|
||||
try {
|
||||
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : linkCredentialFields(credentialValues);
|
||||
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry, method?.key) : linkCredentialFields(credentialValues);
|
||||
for (const field of credentialFields) {
|
||||
const value = credentialValues[field.configPath];
|
||||
if (!value && field.required !== false) {
|
||||
@@ -4952,6 +5051,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
if (revivedConnectionPrevious) {
|
||||
[connectionRow] = await db.update(toolConnections).set({
|
||||
name,
|
||||
authKind: method?.auth ?? "none",
|
||||
transport,
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
@@ -4970,7 +5070,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
name,
|
||||
uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId),
|
||||
connectionKind: "managed",
|
||||
authKind: galleryEntry ? connectionMethodFor(galleryEntry).auth : "none",
|
||||
authKind: method?.auth ?? "none",
|
||||
transport,
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
@@ -4985,14 +5085,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
await syncCredentialBindings(connectionRow);
|
||||
await ensureRuntimeSlot(connectionRow);
|
||||
|
||||
if (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth") {
|
||||
if (galleryEntry && method?.auth === "oauth") {
|
||||
return {
|
||||
connectionId: connectionRow.id,
|
||||
application: toApplication(applicationRow),
|
||||
connection: toConnection(connectionRow),
|
||||
catalog: [],
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
suggestedDefaults: recommendedDefaultsForApp(galleryEntry),
|
||||
suggestedDefaults: recommendedDefaultsForApp(galleryEntry, method.key),
|
||||
auth: { kind: "oauth", startUrl: null },
|
||||
};
|
||||
}
|
||||
@@ -5027,7 +5127,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
connection: refresh.connection,
|
||||
catalog: refresh.catalog,
|
||||
actions: groupedActions(refresh.catalog),
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : {
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry, method?.key) : {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
@@ -5401,7 +5501,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
const sourceTemplateKey =
|
||||
typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
|
||||
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : [
|
||||
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key) : [
|
||||
{
|
||||
label: "App key",
|
||||
configPath: "credentials.authorization",
|
||||
@@ -5736,7 +5836,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
connection: toConnection(connection),
|
||||
catalog,
|
||||
actions: groupedActions(catalog),
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] },
|
||||
suggestedDefaults: galleryEntry
|
||||
? recommendedDefaultsForApp(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key)
|
||||
: { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] },
|
||||
auth: null,
|
||||
};
|
||||
}
|
||||
@@ -5801,7 +5903,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
connection: refresh.connection,
|
||||
catalog: refresh.catalog,
|
||||
actions: groupedActions(refresh.catalog),
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : {
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(
|
||||
galleryEntry,
|
||||
connectionMethodForConnection(galleryEntry, connection).key,
|
||||
) : {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
|
||||
@@ -66,6 +66,10 @@ const APP_COPY: Record<string, AppCopy> = {
|
||||
tagline: "Read and update pages in your workspace.",
|
||||
short: "Read and update pages in your workspace.",
|
||||
},
|
||||
posthog: {
|
||||
tagline: "Explore product usage, errors, flags, and experiments.",
|
||||
short: "Choose one project and the analytics tools your agents can use.",
|
||||
},
|
||||
linear: {
|
||||
tagline: "Create, update and read tickets.",
|
||||
short: "Create, update and read tickets.",
|
||||
|
||||
@@ -23,6 +23,7 @@ const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }
|
||||
|
||||
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
|
||||
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!;
|
||||
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
|
||||
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
@@ -211,6 +212,50 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
});
|
||||
|
||||
it("requires a PostHog method and submits the selected project scope", async () => {
|
||||
mockParams.appKey = "posthog";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
expect(buttonByText("Sign in with PostHog")?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(buttonByText("Use a personal API key")?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const projectInput = container.querySelector<HTMLInputElement>('input[placeholder="12345"]');
|
||||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||||
expect(projectInput).toBeTruthy();
|
||||
expect(keyInput).toBeTruthy();
|
||||
await act(async () => {
|
||||
setInputValue(projectInput!, "12345");
|
||||
setInputValue(keyInput!, "phx_test-key");
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", {
|
||||
galleryKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
name: "PostHog",
|
||||
credentialValues: { "credentials.authorization": "phx_test-key" },
|
||||
configValues: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights,error_tracking,flags,experiments",
|
||||
mode: "tools",
|
||||
},
|
||||
applicationId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-starts the allowlisted Notion source deep link and opens provider sign-in", async () => {
|
||||
mockSearch.value = "source=notion";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||||
|
||||
@@ -16,12 +16,14 @@ import type { LucideIcon } from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
AppDefinition,
|
||||
ConnectionMethodDef,
|
||||
ConnectToolAppResult,
|
||||
FieldDef,
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolAppConnectionActionSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod } from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, getAvailableConnectionMethods } from "@paperclipai/shared";
|
||||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
@@ -93,6 +95,15 @@ function isGoogleSheetsEntry(entry: AppDefinition | null): boolean {
|
||||
return entry?.slug === "google-sheets";
|
||||
}
|
||||
|
||||
function defaultMethodConfig(method: ConnectionMethodDef | null): Record<string, string | boolean> {
|
||||
if (!method) return {};
|
||||
return Object.fromEntries(
|
||||
[...(method.tenantFields ?? []), ...(method.extensionFields ?? [])]
|
||||
.filter((field) => field.defaultValue !== undefined)
|
||||
.map((field) => [field.key, field.defaultValue!]),
|
||||
);
|
||||
}
|
||||
|
||||
function appSourceSlug(application: ToolApplication): string | null {
|
||||
const metadata = application.metadata;
|
||||
if (!metadata) return null;
|
||||
@@ -165,6 +176,8 @@ export function AppsConnect() {
|
||||
const [linkNeedsKey, setLinkNeedsKey] = useState(false);
|
||||
const [linkKey, setLinkKey] = useState("");
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>({});
|
||||
const [connectionMethodKey, setConnectionMethodKey] = useState("");
|
||||
const [configValues, setConfigValues] = useState<Record<string, string | boolean>>({});
|
||||
const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
|
||||
const [googleSheetsError, setGoogleSheetsError] = useState<string | null>(null);
|
||||
const [connectResult, setConnectResult] = useState<ConnectToolAppResult | null>(null);
|
||||
@@ -186,6 +199,8 @@ export function AppsConnect() {
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
setCredentials({});
|
||||
setConnectionMethodKey("");
|
||||
setConfigValues({});
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
@@ -274,9 +289,14 @@ export function AppsConnect() {
|
||||
const trimmedGalleryName = galleryName.trim();
|
||||
return toolsApi.connectApp(selectedCompanyId!, {
|
||||
galleryKey: connectEntry.slug,
|
||||
...(connectionMethodKey ? { connectionMethodKey } : {}),
|
||||
name: trimmedGalleryName || connectEntry.name,
|
||||
credentialValues: credentials,
|
||||
configValues: isGoogleSheetsEntry(connectEntry) ? { allowedSpreadsheetIds: sheetIds } : undefined,
|
||||
configValues: isGoogleSheetsEntry(connectEntry)
|
||||
? { allowedSpreadsheetIds: sheetIds }
|
||||
: Object.keys(configValues).length > 0
|
||||
? configValues
|
||||
: undefined,
|
||||
applicationId: prefill.applicationId,
|
||||
});
|
||||
}
|
||||
@@ -345,8 +365,9 @@ export function AppsConnect() {
|
||||
|
||||
const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === requestedAppKey);
|
||||
const method = requestedEntry ? getAvailableConnectionMethod(requestedEntry) : null;
|
||||
const methods = requestedEntry ? getAvailableConnectionMethods(requestedEntry) : [];
|
||||
const directOAuth = method?.auth === "oauth" && isMcpDirectOAuthConnectSlug(requestedEntry?.slug);
|
||||
const unsupportedOAuth = method?.auth === "oauth" && !directOAuth;
|
||||
const unsupportedOAuth = methods.length === 1 && method?.auth === "oauth" && !directOAuth;
|
||||
if (!requestedEntry || unsupportedOAuth || requestedEntry.availability?.available === false) {
|
||||
setEntry(null);
|
||||
setStep("gallery");
|
||||
@@ -362,6 +383,10 @@ export function AppsConnect() {
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
setCredentials({});
|
||||
const methods = getAvailableConnectionMethods(requestedEntry);
|
||||
const initialMethod = methods.length === 1 ? methods[0]! : null;
|
||||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||||
setConfigValues(defaultMethodConfig(initialMethod));
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
@@ -507,6 +532,8 @@ export function AppsConnect() {
|
||||
: null;
|
||||
const stepLabels = zapierSource
|
||||
? ZAPIER_STEP_LABELS
|
||||
: entry && getAvailableConnectionMethods(entry).length > 1
|
||||
? ["Pick app", "Choose connection", "Choose actions", "Choose access", "Install tools"]
|
||||
: isGoogleSheetsEntry(entry)
|
||||
? ["Pick app", "Share sheet", "Choose actions", "Choose access", "Install tools"]
|
||||
: STEP_LABELS;
|
||||
@@ -558,6 +585,10 @@ export function AppsConnect() {
|
||||
setLinkNeedsKey(false);
|
||||
setLinkKey("");
|
||||
setCredentials({});
|
||||
const methods = getAvailableConnectionMethods(picked);
|
||||
const initialMethod = methods.length === 1 ? methods[0]! : null;
|
||||
setConnectionMethodKey(initialMethod?.key ?? "");
|
||||
setConfigValues(defaultMethodConfig(initialMethod));
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
@@ -593,6 +624,14 @@ export function AppsConnect() {
|
||||
onNameChange={setGalleryName}
|
||||
values={credentials}
|
||||
onChange={setCredentials}
|
||||
methodKey={connectionMethodKey}
|
||||
onMethodChange={(nextMethod) => {
|
||||
setConnectionMethodKey(nextMethod.key);
|
||||
setCredentials({});
|
||||
setConfigValues(defaultMethodConfig(nextMethod));
|
||||
}}
|
||||
configValues={configValues}
|
||||
onConfigChange={setConfigValues}
|
||||
googleSheetsLinks={googleSheetsLinks}
|
||||
googleSheetsError={googleSheetsError}
|
||||
onGoogleSheetsLinksChange={(next) => {
|
||||
@@ -978,8 +1017,9 @@ 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.slug, app.description);
|
||||
const oauth = getAvailableConnectionMethod(app)?.auth === "oauth";
|
||||
const oauthBlocked = oauth && !isMcpDirectOAuthConnectSlug(app.slug);
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
const oauth = methods[0]?.auth === "oauth";
|
||||
const oauthBlocked = methods.length === 1 && oauth && !isMcpDirectOAuthConnectSlug(app.slug);
|
||||
const unavailable = app.availability?.available === false;
|
||||
return (
|
||||
<button
|
||||
@@ -1336,6 +1376,10 @@ function KeyStep({
|
||||
onNameChange,
|
||||
values,
|
||||
onChange,
|
||||
methodKey,
|
||||
onMethodChange,
|
||||
configValues,
|
||||
onConfigChange,
|
||||
googleSheetsLinks,
|
||||
googleSheetsError,
|
||||
onGoogleSheetsLinksChange,
|
||||
@@ -1348,6 +1392,10 @@ function KeyStep({
|
||||
onNameChange: (next: string) => void;
|
||||
values: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
methodKey: string;
|
||||
onMethodChange: (method: ConnectionMethodDef) => void;
|
||||
configValues: Record<string, string | boolean>;
|
||||
onConfigChange: (next: Record<string, string | boolean>) => void;
|
||||
googleSheetsLinks: string;
|
||||
googleSheetsError: string | null;
|
||||
onGoogleSheetsLinksChange: (next: string) => void;
|
||||
@@ -1356,7 +1404,10 @@ function KeyStep({
|
||||
onConnect: () => void;
|
||||
}) {
|
||||
const copy = appCopyFor(entry.slug, entry.description);
|
||||
const method = getAvailableConnectionMethod(entry);
|
||||
const methods = getAvailableConnectionMethods(entry);
|
||||
const method = methods.length > 1 && !methodKey
|
||||
? null
|
||||
: getAvailableConnectionMethod(entry, methodKey || null);
|
||||
const fields = (method?.credentialFields ?? []).map((field) => ({
|
||||
...field,
|
||||
configPath: credentialConfigPath(field),
|
||||
@@ -1365,6 +1416,18 @@ function KeyStep({
|
||||
const allFilled = fields.every(
|
||||
(f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0,
|
||||
);
|
||||
const configFields = [...(method?.tenantFields ?? []), ...(method?.extensionFields ?? [])];
|
||||
const configFilled = configFields.every((field) => {
|
||||
if (!field.required) return true;
|
||||
const value = configValues[field.key];
|
||||
return typeof value === "boolean" || (typeof value === "string" && value.trim().length > 0);
|
||||
});
|
||||
const alternativeKeys = method?.configRequirements?.atLeastOneOf ?? [];
|
||||
const configRequirementMet = alternativeKeys.length === 0 || alternativeKeys.some((key) => {
|
||||
const value = configValues[key];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
});
|
||||
const hasMethodSelection = methods.length <= 1 || Boolean(methodKey);
|
||||
const robotEmail = entry.availability?.robotEmail ?? null;
|
||||
const unavailable = entry.availability?.available === false;
|
||||
|
||||
@@ -1455,11 +1518,39 @@ function KeyStep({
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
{methods.length > 1 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">How do you want to connect?</label>
|
||||
<div className="mt-2 inline-flex rounded-lg bg-muted p-1">
|
||||
{methods.map((candidate) => (
|
||||
<SegmentedOption
|
||||
key={candidate.key}
|
||||
label={candidate.label ?? (candidate.auth === "oauth" ? `Sign in with ${entry.name}` : "Use an API key")}
|
||||
selected={candidate.key === methodKey}
|
||||
onClick={() => onMethodChange(candidate)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!method && <p className="mt-2 text-xs text-muted-foreground">Choose a method to continue.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionNameField name={name} onNameChange={onNameChange} />
|
||||
|
||||
{fields.length === 0 ? (
|
||||
{configFields.map((field) => (
|
||||
<MethodConfigField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={configValues[field.key]}
|
||||
onChange={(value) => onConfigChange({ ...configValues, [field.key]: value })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{method && fields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This app doesn’t need a key. Just connect to continue.
|
||||
{method.auth === "oauth"
|
||||
? `You’ll continue to ${entry.name} to sign in securely.`
|
||||
: "This app doesn’t need a key. Just connect to continue."}
|
||||
</p>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
@@ -1490,15 +1581,17 @@ function KeyStep({
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Your key is stored securely.</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
You can replace it anytime from this app’s page.
|
||||
{method?.auth === "api_key" && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-muted/50 p-4">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Your key is stored securely.</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
You can replace it anytime from this app’s page.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
@@ -1507,11 +1600,13 @@ function KeyStep({
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
We’ll check the key before turning anything on.
|
||||
{method?.auth === "oauth"
|
||||
? "You’ll sign in before anything turns on."
|
||||
: "We’ll check the key before turning anything on."}
|
||||
</span>
|
||||
<Button onClick={onConnect} disabled={submitting || !allFilled}>
|
||||
<Button onClick={onConnect} disabled={submitting || !hasMethodSelection || !allFilled || !configFilled || !configRequirementMet}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting ? "Checking…" : "Connect"}
|
||||
{submitting ? "Checking…" : method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1519,6 +1614,58 @@ function KeyStep({
|
||||
);
|
||||
}
|
||||
|
||||
function MethodConfigField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldDef;
|
||||
value: string | boolean | undefined;
|
||||
onChange: (value: string | boolean) => void;
|
||||
}) {
|
||||
if (field.type === "checkbox") {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{field.label}</div>
|
||||
{field.helperMd && <div className="mt-1 text-xs text-muted-foreground">{field.helperMd}</div>}
|
||||
</div>
|
||||
<ToggleSwitch checked={value === true} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{field.label}</label>
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="mt-2 min-h-24"
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 h-11 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
>
|
||||
<option value="" disabled>Select an option</option>
|
||||
{(field.options ?? []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="mt-2 h-11"
|
||||
/>
|
||||
)}
|
||||
{field.helperMd && <p className="mt-2 text-xs text-muted-foreground">{field.helperMd}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionGroup({
|
||||
title,
|
||||
hint,
|
||||
|
||||
@@ -29,6 +29,7 @@ function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
|
||||
const slug = appDefinitionSlug(entry);
|
||||
if (slug === "notion") return NOTION_CONNECT_HREF;
|
||||
if (slug === "zapier") return ZAPIER_CONNECT_HREF;
|
||||
if (slug === "posthog") return "/apps/connect?byo=1&appKey=posthog&stage=setup";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,8 +140,11 @@ function ReconnectForm({
|
||||
onReconnected: () => void;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const methodKey = typeof connection.config?.connectionMethodKey === "string"
|
||||
? connection.config.connectionMethodKey
|
||||
: null;
|
||||
const method = galleryEntry && Array.isArray(galleryEntry.methods)
|
||||
? getAvailableConnectionMethod(galleryEntry)
|
||||
? getAvailableConnectionMethod(galleryEntry, methodKey)
|
||||
: null;
|
||||
const fields = (method?.credentialFields ?? []).map((field) => ({
|
||||
...field,
|
||||
|
||||
@@ -43,10 +43,17 @@ export function SetupPanel({
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
{appDefinitionSlug(galleryEntry) === "posthog" && (
|
||||
<PostHogConfigurationSection connection={connection} />
|
||||
)}
|
||||
{hasOAuthSignIn && (
|
||||
<OAuthConnectionSection
|
||||
connected={Boolean((oauth as Record<string, unknown>).connectedAt)}
|
||||
providerName={appDefinitionSlug(galleryEntry) === "notion" ? "Notion" : isSmokeLabFixture ? "Smoke OAuth" : "OAuth"}
|
||||
providerName={appDefinitionSlug(galleryEntry) === "notion"
|
||||
? "Notion"
|
||||
: appDefinitionSlug(galleryEntry) === "posthog"
|
||||
? "PostHog"
|
||||
: isSmokeLabFixture ? "Smoke OAuth" : "OAuth"}
|
||||
disabled={oauthStartDisabled}
|
||||
onStart={onStartOAuth}
|
||||
/>
|
||||
@@ -56,6 +63,40 @@ export function SetupPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function PostHogConfigurationSection({ connection }: { connection: ToolConnection }) {
|
||||
const raw = connection.config?.methodConfig;
|
||||
const config = raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? raw as Record<string, unknown>
|
||||
: {};
|
||||
const method = connection.config?.connectionMethodKey === "mcp-oauth" ? "PostHog sign-in" : "Personal API key";
|
||||
const features = typeof config.features === "string" ? config.features : "None";
|
||||
const tools = typeof config.tools === "string" && config.tools ? config.tools : "None";
|
||||
const rows = [
|
||||
["Connection method", method],
|
||||
["Project ID", typeof config.projectId === "string" ? config.projectId : "Not set"],
|
||||
["Read-only mode", config.readOnly === true ? "On" : "Off"],
|
||||
["Feature groups", features],
|
||||
["Individual tools", tools],
|
||||
["Response mode", typeof config.mode === "string" ? config.mode : "tools"],
|
||||
];
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<h2 className="text-sm font-bold text-foreground">PostHog access scope</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
This connection is pinned to the project and analytics surface below.
|
||||
</p>
|
||||
<dl className="mt-4 divide-y divide-border">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="grid gap-1 py-2 sm:grid-cols-3 sm:gap-4">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="break-words text-sm text-foreground sm:col-span-2">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthConnectionSection({
|
||||
connected,
|
||||
providerName,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { advancedTabHref } from "@/pages/tools/tool-tabs";
|
||||
import { appSourceConnectHref } from "./app-connect-policy";
|
||||
|
||||
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
|
||||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "linear"];
|
||||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "posthog", "linear"];
|
||||
|
||||
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
|
||||
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
|
||||
|
||||
Reference in new issue
Block a user