feat(apps): add governed PostHog connector

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:01 +00:00
1 parent de9645ab73
commit b5301d3ab3
22 files changed
+890 -69

No files matched your search

+1 -1
View File
@@ -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 |
+81
View File
@@ -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
View File
@@ -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[];
+2 -1
View File
@@ -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()}});
});
+14 -5
View File
@@ -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"
}
}
]
}
+1
View File
@@ -239,6 +239,7 @@ export {
credentialConfigPath,
getAppDefinitionForUrl,
getAvailableConnectionMethod,
getAvailableConnectionMethods,
getConnectableAppDefinition,
recommendedDefaultsForApp,
} from "./app-definitions.js";
+2 -2
View File
@@ -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" },
+10 -1
View File
@@ -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");
});
});
+131 -26
View File
@@ -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"],
},
+4
View File
@@ -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.",
+45
View File
@@ -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] });
+165 -18
View File
@@ -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,
+1
View File
@@ -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,
+42 -1
View File
@@ -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,
+1 -1
View File
@@ -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";