mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connections give agents controlled access to external services. > - Experimental channels already map conversations to tasks and durable work queues. > - Email needs inbox ownership, recipient envelopes, delivery records, and explicit sends. > - This pull request adds AgentMail to that infrastructure and keeps the provider key in the server vault. > - Agents can receive and send email from local or sandbox execution while the board follows each conversation in its task. ## Linked Issues or Issue Description **Problem or motivation** Agents need dedicated email addresses. Incoming email should become assigned work. Internal task comments and progress must never become outgoing email by accident. **Proposed solution** Add experimental AgentMail connections, an inbox assignment wizard, durable email intake and publication, task email cards, and authenticated API, CLI, and native runtime actions. Agents use Paperclip credentials to request sends. Paperclip owns the provider key and enforces access and task authority. **Alternatives considered** A general mailbox MCP connector does not provide durable task binding or publication boundaries. A separate mailbox application duplicates task collaboration. The board instead directs the agent through the normal task conversation. **Roadmap alignment** This extends the existing experimental connections and task infrastructure. Product scope and interaction design were reviewed with the maintainer. Related connection authority work: #11831 and #11818. The duplicate search found no competing task-based AgentMail integration. ## What Changed - Add AgentMail catalog data, shared contracts, company-scoped email records, and an additive migration. - Add vaulted setup, inbox assignment, access grants, trust guidance, and provider-side allowlist guidance. - Support WebSocket and signed-webhook intake through a shared durable pipeline, deduplication, catch-up, and task wakeups. - Queue explicit new conversations and replies with immutable send intents, idempotency, delivery state, and uncertain-send resolution. - Show inbound and outbound email cards in normal task conversations. Keep internal messages internal. - Add task-scoped CLI actions and the sandbox callback routes required for Daytona execution. - Provide a dedicated AgentMail skill automatically only to agents with active authorized inbox assignments. Keep email instructions out of the universal Paperclip skill. - Advertise connector-owned `agentmail_inboxes`, `agentmail_read_thread`, `agentmail_send`, and `agentmail_delivery` tools only in eligible native sessions. Recheck live authority on execution. - Isolate Codex CLI connector skills by agent and skill revision. Deliver the assigned skill in the run prompt for adapters that use shared skill directories, including resumed turns. Keep automatic skills out of manual persistent sync. Show them as read-only and document the pattern in the connector playbook. - Fix AgentMail health checks that entered local-stdio validation and optional missing Codex credential cleanup in sandboxes. - Add API, pipeline, authorization, sandbox, browser, and Storybook coverage. ## Verification - Live AgentMail testing covered WebSocket intake, signed webhooks, restart catch-up, and a full receive → task → Daytona Codex CLI → explicit reply → Delivered round trip. The reply was verified in the other inbox. The normal task composer also initiated an outgoing email child task. - The connector-skill change was verified in the browser: AgentMail appears once as an automatic, read-only skill with its assigned address. Disabling experimental chat connections removes it; re-enabling restores it. A regression test covers assignment data arriving after library data. - Connector regression coverage passed 178 runtime utility, email integration, skill-route, and heartbeat tests. All 17 Codex execution tests passed, including per-agent skill isolation, model identity, revision changes, removal, and prompt delivery without shared skill files. - After rebasing onto master, all 44 focused email, heartbeat, and native-authority tests passed. All 313 native-session executor tests passed. The UI regression suite passed all 3 tests. These test sets overlap earlier focused runs. - Full workspace typecheck and build passed after the rebase. Token gates passed. Earlier focused Playwright task/setup coverage and the Storybook build also passed. - Native connector tool execution uses deterministic integration tests. Live Daytona qualification used the Codex CLI adapter; the new shared-home prompt fallback has deterministic coverage. - The full repository suite is run by CI. The earlier unsharded local full-suite attempt was stopped after the equivalent CI suites passed and is not reported as a completed local run. Greptile reviewed `7e57dc267a8446d3c906e3cc5b8abc94fb8860eb` at 5/5 with no unresolved threads. All server, workspace, serialized server, and browser suites passed in CI. The build job hit a five-second timeout in a runner transport test; both variants and the full 80-test file passed locally with unchanged timeouts. The build passed on retry on the same commit without code or timeout changes. All required CI gates, including the final `ci / verify` and `ci / e2e` summaries, are green on `7e57dc267a8446d3c906e3cc5b8abc94fb8860eb`. ## Risks - Email from external senders can start normal agent work. Setup recommends a low-trust agent and AgentMail sender controls. Sender addresses never grant board membership. - Provider timeouts can leave uncertain sends. Retries retain their idempotency key; expired windows require reconciliation or operator resolution. - Connector skills and native tools are assignment-dependent and require current access. Revocation denies retained calls; assignment changes select a new runtime context. - Activation remains behind the experimental-channel setting. The native runner path has deterministic coverage; live Daytona qualification used the Codex CLI adapter. - Schema changes are additive. Inbox ownership is unique across companies. Disconnect preserves provider inboxes and task history. ## Model Used OpenAI GPT-6 (Codex). Used reasoning, repository tools, code execution, and browser testing. The exact deployment model ID and context-window size were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
983 lines
32 KiB
TypeScript
983 lines
32 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { APP_DEFINITIONS } from "./app-definitions.generated.js";
|
|
import {
|
|
APP_STORE_DEFINITIONS,
|
|
APP_STORE_HIDDEN_SLUGS,
|
|
CONNECTABLE_APP_DEFINITIONS,
|
|
appSupportsCatalogSetup,
|
|
getAvailableConnectionMethod,
|
|
getRecommendedConnectionMethod,
|
|
recommendedDefaultsForApp,
|
|
resolveConnectionMethodServerUrl,
|
|
} from "./app-definitions.js";
|
|
import {
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
|
type GoogleWorkspaceConnectorProfileId,
|
|
} from "./google-workspace-connectors.js";
|
|
import {
|
|
BLOCKED_MCP_PROVIDERS,
|
|
SELF_SERVE_MCP_CANDIDATES,
|
|
SELF_SERVE_MCP_RESEARCH,
|
|
} from "./self-serve-mcp-research.js";
|
|
import { appDefinitionsSchema } from "./validators/app-definition.js";
|
|
|
|
const googleScope = (scope: string) =>
|
|
`https://www.googleapis.com/auth/${scope}`;
|
|
const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [
|
|
{
|
|
profile: "gmail.read",
|
|
appSlug: "gmail",
|
|
serverUrl: "https://gmailmcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [googleScope("gmail.readonly")],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "gmail.draft",
|
|
appSlug: "gmail",
|
|
serverUrl: "https://gmailmcp.googleapis.com/mcp/v1",
|
|
capability: "draft",
|
|
riskTier: "S4",
|
|
scopes: [googleScope("gmail.readonly"), googleScope("gmail.compose")],
|
|
writeTools: ["create_draft"],
|
|
},
|
|
{
|
|
profile: "drive.read",
|
|
appSlug: "google-drive",
|
|
serverUrl: "https://drivemcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [googleScope("drive.readonly")],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "drive.write",
|
|
appSlug: "google-drive",
|
|
serverUrl: "https://drivemcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [googleScope("drive.readonly"), googleScope("drive.file")],
|
|
writeTools: ["copy_file", "create_file"],
|
|
},
|
|
{
|
|
profile: "docs.read",
|
|
appSlug: "google-docs",
|
|
serverUrl: "https://docsmcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [googleScope("drive.readonly"), googleScope("documents.readonly")],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "docs.write",
|
|
appSlug: "google-docs",
|
|
serverUrl: "https://docsmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [
|
|
googleScope("drive.readonly"),
|
|
googleScope("drive.file"),
|
|
googleScope("documents"),
|
|
],
|
|
writeTools: ["update_doc"],
|
|
},
|
|
{
|
|
profile: "sheets.read",
|
|
appSlug: "google-sheets",
|
|
serverUrl: "https://sheetsmcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [
|
|
googleScope("drive.readonly"),
|
|
googleScope("spreadsheets.readonly"),
|
|
],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "sheets.write",
|
|
appSlug: "google-sheets",
|
|
serverUrl: "https://sheetsmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [
|
|
googleScope("drive.readonly"),
|
|
googleScope("drive.file"),
|
|
googleScope("spreadsheets"),
|
|
],
|
|
writeTools: [
|
|
"update_spreadsheet",
|
|
"update_values",
|
|
"update_formulas",
|
|
"insert_dimension",
|
|
],
|
|
},
|
|
{
|
|
profile: "slides.read",
|
|
appSlug: "google-slides",
|
|
serverUrl: "https://slidesmcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [
|
|
googleScope("drive.readonly"),
|
|
googleScope("presentations.readonly"),
|
|
],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "slides.write",
|
|
appSlug: "google-slides",
|
|
serverUrl: "https://slidesmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [
|
|
googleScope("drive.readonly"),
|
|
googleScope("drive.file"),
|
|
googleScope("presentations"),
|
|
],
|
|
writeTools: ["update_presentation"],
|
|
},
|
|
{
|
|
profile: "calendar.read",
|
|
appSlug: "google-calendar",
|
|
serverUrl: "https://calendarmcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [
|
|
googleScope("calendar.calendarlist.readonly"),
|
|
googleScope("calendar.events.freebusy"),
|
|
googleScope("calendar.events.readonly"),
|
|
],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "calendar.write",
|
|
appSlug: "google-calendar",
|
|
serverUrl: "https://calendarmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [
|
|
googleScope("calendar.calendarlist.readonly"),
|
|
googleScope("calendar.events.freebusy"),
|
|
googleScope("calendar.events"),
|
|
],
|
|
writeTools: [
|
|
"create_event",
|
|
"update_event",
|
|
"delete_event",
|
|
"respond_to_event",
|
|
],
|
|
},
|
|
{
|
|
profile: "chat.read",
|
|
appSlug: "google-chat",
|
|
serverUrl: "https://chatmcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [
|
|
googleScope("chat.spaces.readonly"),
|
|
googleScope("chat.memberships.readonly"),
|
|
googleScope("chat.messages.readonly"),
|
|
googleScope("chat.users.readstate.readonly"),
|
|
],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "chat.write",
|
|
appSlug: "google-chat",
|
|
serverUrl: "https://chatmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [
|
|
googleScope("chat.spaces.readonly"),
|
|
googleScope("chat.memberships.readonly"),
|
|
googleScope("chat.messages.readonly"),
|
|
googleScope("chat.users.readstate.readonly"),
|
|
googleScope("chat.messages.create"),
|
|
],
|
|
writeTools: ["send_message"],
|
|
},
|
|
{
|
|
profile: "people.read",
|
|
appSlug: "google-people",
|
|
serverUrl: "https://people.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [
|
|
googleScope("directory.readonly"),
|
|
googleScope("userinfo.profile"),
|
|
googleScope("contacts.readonly"),
|
|
],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "workspace-search.read",
|
|
appSlug: "google-workspace-search",
|
|
serverUrl: "https://workspacemcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [
|
|
googleScope("gmail.readonly"),
|
|
googleScope("drive.readonly"),
|
|
googleScope("calendar.readonly"),
|
|
googleScope("chat.messages.readonly"),
|
|
],
|
|
writeTools: [],
|
|
},
|
|
] as const satisfies ReadonlyArray<{
|
|
profile: GoogleWorkspaceConnectorProfileId;
|
|
appSlug: string;
|
|
serverUrl: string;
|
|
capability: "read" | "write" | "draft";
|
|
riskTier: "S3" | "S4";
|
|
scopes: readonly string[];
|
|
writeTools: readonly string[];
|
|
}>;
|
|
describe("AppDefinition catalog", () => {
|
|
it("validates all Wave 1 definitions", () =>
|
|
expect(() => appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow());
|
|
it("contains every established provider plus the reviewed self-serve catalog", () => {
|
|
expect(APP_DEFINITIONS.map((app) => app.slug)).toEqual(
|
|
expect.arrayContaining([
|
|
"zapier",
|
|
"github",
|
|
"discord",
|
|
"slack",
|
|
"microsoft-teams",
|
|
"telegram",
|
|
"notion",
|
|
"posthog",
|
|
"linear",
|
|
"google-sheets",
|
|
"context7",
|
|
"composio",
|
|
"oauth-generic",
|
|
"api-key-generic",
|
|
"sentry",
|
|
"vercel",
|
|
"anthropic",
|
|
"gmail",
|
|
"google-drive",
|
|
"google-docs",
|
|
"google-slides",
|
|
"google-calendar",
|
|
"google-chat",
|
|
"google-people",
|
|
"google-workspace-search",
|
|
]),
|
|
);
|
|
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(43);
|
|
expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([
|
|
"g2",
|
|
"vercel",
|
|
"zomato",
|
|
]);
|
|
const definitionSlugs = new Set(APP_DEFINITIONS.map((app) => app.slug));
|
|
const connectableSlugs = new Set(
|
|
CONNECTABLE_APP_DEFINITIONS.map((app) => app.slug),
|
|
);
|
|
expect(
|
|
SELF_SERVE_MCP_CANDIDATES.filter(
|
|
(entry) => !definitionSlugs.has(entry.slug),
|
|
),
|
|
).toEqual([]);
|
|
expect(
|
|
SELF_SERVE_MCP_CANDIDATES.filter(
|
|
(entry) => !connectableSlugs.has(entry.slug),
|
|
),
|
|
).toEqual([]);
|
|
for (const entry of BLOCKED_MCP_PROVIDERS)
|
|
expect(connectableSlugs.has(entry.slug)).toBe(false);
|
|
});
|
|
it("registers the five native chat providers with only required setup credentials", () => {
|
|
const expected = {
|
|
slack: {
|
|
credentials: ["botToken", "signingSecret"],
|
|
publicFields: [],
|
|
resources: ["workspace", "channel"],
|
|
tool: true,
|
|
},
|
|
github: {
|
|
credentials: ["appId", "privateKey"],
|
|
publicFields: ["appId"],
|
|
resources: ["organization", "repository"],
|
|
tool: true,
|
|
},
|
|
discord: {
|
|
credentials: ["botToken", "applicationId", "guildId"],
|
|
publicFields: ["applicationId", "guildId"],
|
|
resources: ["channel"],
|
|
tool: false,
|
|
},
|
|
"microsoft-teams": {
|
|
credentials: ["clientId", "tenantId", "clientSecret"],
|
|
publicFields: ["clientId", "tenantId"],
|
|
resources: ["team", "channel", "chat"],
|
|
tool: false,
|
|
},
|
|
telegram: {
|
|
credentials: ["botToken"],
|
|
publicFields: [],
|
|
resources: ["chat", "group", "topic"],
|
|
tool: false,
|
|
},
|
|
} as const;
|
|
for (const [slug, contract] of Object.entries(expected)) {
|
|
const app = CONNECTABLE_APP_DEFINITIONS.find(
|
|
(candidate) => candidate.slug === slug,
|
|
);
|
|
const channel = app?.methods.find(
|
|
(candidate) => candidate.purpose === "channel",
|
|
);
|
|
expect(app, slug).toBeTruthy();
|
|
expect(channel, slug).toMatchObject({
|
|
key: "chat-agent",
|
|
label: "Chat with an agent",
|
|
purpose: "channel",
|
|
provider: slug,
|
|
transport: "chat_sdk",
|
|
auth: "api_key",
|
|
ownershipModes: ["customer"],
|
|
requiredResourceFilters: contract.resources,
|
|
});
|
|
expect(
|
|
channel?.credentialFields?.map((field) => field.key),
|
|
slug,
|
|
).toEqual(contract.credentials);
|
|
expect(
|
|
channel?.credentialFields?.every((field) => field.required),
|
|
slug,
|
|
).toBe(true);
|
|
for (const field of channel?.credentialFields ?? []) {
|
|
if ((contract.publicFields as readonly string[]).includes(field.key))
|
|
expect(field, `${slug}/${field.key}`).toMatchObject({
|
|
type: "text",
|
|
secret: false,
|
|
});
|
|
else expect(field.secret, `${slug}/${field.key}`).toBe(true);
|
|
}
|
|
expect(channel?.keyPlacement, slug).toBeUndefined();
|
|
const toolMethods =
|
|
app?.methods.filter(
|
|
(candidate) => (candidate.purpose ?? "tool") === "tool",
|
|
) ?? [];
|
|
expect(toolMethods.length > 0, `${slug}: tool surface`).toBe(
|
|
contract.tool,
|
|
);
|
|
if (contract.tool)
|
|
expect(
|
|
toolMethods.some(
|
|
(candidate) =>
|
|
candidate.label === "Use this connection as an agent tool",
|
|
),
|
|
slug,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
it("documents the minimum provider-owned chat app setup without broader permissions", () => {
|
|
const channel = (slug: string) =>
|
|
APP_DEFINITIONS.find((app) => app.slug === slug)?.methods.find(
|
|
(method) => method.purpose === "channel",
|
|
);
|
|
expect(channel("github")?.guidanceMd).toContain("issue_comment");
|
|
expect(channel("discord")?.guidanceMd).toContain("Message Content intent");
|
|
expect(channel("discord")?.guidanceMd).toContain("Discord thread");
|
|
expect(channel("github")?.guidanceMd).toContain("pull_request");
|
|
expect(channel("github")?.guidanceMd).toContain(
|
|
"pull_request_review_comment",
|
|
);
|
|
expect(channel("github")?.guidanceMd).toContain(
|
|
"installation_repositories",
|
|
);
|
|
expect(channel("github")?.guidanceMd).toContain(
|
|
"Generate the webhook secret in Paperclip",
|
|
);
|
|
expect(channel("github")?.guidanceMd).toContain("SSL-verified");
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain(
|
|
"resource-specific",
|
|
);
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain(
|
|
"ChannelMessage.Read.Group",
|
|
);
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain(
|
|
"ChatMessage.Read.Chat",
|
|
);
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain(
|
|
"work or school organization",
|
|
);
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain("teams.live.com");
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain("groupChat");
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain(
|
|
"receive every message",
|
|
);
|
|
expect(channel("microsoft-teams")?.guidanceMd).toContain(
|
|
"One team install covers its standard channels",
|
|
);
|
|
expect(channel("telegram")?.guidanceMd).toContain(
|
|
"public Paperclip webhook endpoint",
|
|
);
|
|
expect(channel("slack")?.guidanceMd).toContain("reactions");
|
|
expect(channel("slack")?.guidanceMd).toContain("direct messages");
|
|
});
|
|
it("keeps a complete, unique, dated evidence ledger for all 46 researched MCP providers", () => {
|
|
expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26");
|
|
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(46);
|
|
expect(
|
|
new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)),
|
|
).toHaveProperty("size", 46);
|
|
for (const entry of SELF_SERVE_MCP_RESEARCH.entries) {
|
|
expect(new URL(entry.docsUrl).protocol).toBe("https:");
|
|
expect(new URL(entry.serverUrl).protocol).toBe("https:");
|
|
expect(entry.authMode).toBeTruthy();
|
|
expect(entry.prerequisite.length).toBeGreaterThan(10);
|
|
expect(["S1", "S2", "S3", "S4"]).toContain(entry.riskTier);
|
|
}
|
|
});
|
|
it("uses the reviewed current endpoints and configuration modes", () => {
|
|
const method = (slug: string, key?: string) =>
|
|
APP_DEFINITIONS.find((app) => app.slug === slug)?.methods.find(
|
|
(candidate) => !key || candidate.key === key,
|
|
);
|
|
expect(method("jira")?.defaults?.serverUrl).toBe(
|
|
"https://mcp.atlassian.com/v1/mcp/authv2",
|
|
);
|
|
expect(method("jira")?.defaults?.scopesHint).toEqual([
|
|
"read:me",
|
|
"read:account",
|
|
"offline_access",
|
|
"email",
|
|
"read:jira-work",
|
|
"write:jira-work",
|
|
"search:confluence",
|
|
"read:confluence-user",
|
|
"read:page:confluence",
|
|
"write:page:confluence",
|
|
"read:comment:confluence",
|
|
"write:comment:confluence",
|
|
"read:space:confluence",
|
|
"read:hierarchical-content:confluence",
|
|
"write:component:compass",
|
|
"read:component:compass",
|
|
"read:scorecard:compass",
|
|
"write:scorecard:compass",
|
|
"read:event:compass",
|
|
"read:metric:compass",
|
|
"read:all:twg",
|
|
"write:all:twg",
|
|
]);
|
|
expect(method("cloudinary")?.defaults?.serverUrl).toBe(
|
|
"https://asset-management.mcp.cloudinary.com/mcp",
|
|
);
|
|
expect(method("kernel")?.defaults?.serverUrl).toBe(
|
|
"https://mcp.onkernel.com/mcp",
|
|
);
|
|
expect(method("resend")?.defaults?.serverUrl).toBe(
|
|
"https://mcp.resend.com/mcp",
|
|
);
|
|
expect(method("clickhouse")?.defaults?.serverUrl).toBe(
|
|
"https://mcp.clickhouse.cloud/clickstack",
|
|
);
|
|
expect(method("clickhouse")?.tenantFields?.[0]?.transport).toEqual({
|
|
location: "header",
|
|
name: "x-service-id",
|
|
});
|
|
expect(method("mem0")).toMatchObject({
|
|
auth: "api_key",
|
|
keyPlacement: {
|
|
location: "header",
|
|
name: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
});
|
|
expect(method("mem0")?.defaults?.serverUrl).toBe(
|
|
"https://mcp.mem0.ai/mcp/",
|
|
);
|
|
expect(method("xero")?.defaults?.scopesHint).toEqual([
|
|
"openid",
|
|
"profile",
|
|
"email",
|
|
"offline_access",
|
|
"accounting.settings",
|
|
"accounting.invoices.read",
|
|
"accounting.reports.aged.read",
|
|
"accounting.reports.balancesheet.read",
|
|
"accounting.reports.profitandloss.read",
|
|
]);
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "pagerduty")?.methods.map(
|
|
(candidate) => ({
|
|
key: candidate.key,
|
|
serverUrl: candidate.defaults?.serverUrl,
|
|
}),
|
|
),
|
|
).toEqual([
|
|
{ key: "mcp-api-key-us", serverUrl: "https://mcp.pagerduty.com/mcp" },
|
|
{ key: "mcp-api-key-eu", serverUrl: "https://mcp.eu.pagerduty.com/mcp" },
|
|
]);
|
|
expect(method("context7")).toMatchObject({
|
|
auth: "none",
|
|
defaults: { serverUrl: "https://mcp.context7.com/mcp" },
|
|
});
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "planetscale")?.methods.map(
|
|
(candidate) => candidate.key,
|
|
),
|
|
).toEqual(["mcp-oauth", "mcp-insights-only"]);
|
|
const postman = APP_DEFINITIONS.find((app) => app.slug === "postman");
|
|
expect(postman?.methods.map((candidate) => candidate.key)).toEqual([
|
|
"mcp-oauth-minimal",
|
|
"mcp-oauth-code",
|
|
"mcp-oauth-full",
|
|
"mcp-eu-key-minimal",
|
|
"mcp-eu-key-code",
|
|
"mcp-eu-key-full",
|
|
]);
|
|
expect(getAvailableConnectionMethod(postman!)?.key).toBe("mcp-oauth-full");
|
|
expect(
|
|
postman?.methods
|
|
.filter((candidate) => candidate.auth === "api_key")
|
|
.every(
|
|
(candidate) =>
|
|
candidate.keyPlacement?.name === "Authorization" &&
|
|
candidate.keyPlacement.prefix === "Bearer ",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
method("supabase")?.tenantFields?.find(
|
|
(field) => field.key === "readOnly",
|
|
)?.defaultValue,
|
|
).toBe(false);
|
|
expect(method("asana")?.ownershipModes).toEqual(["customer"]);
|
|
expect(method("zapier")).toMatchObject({
|
|
key: "generated-url",
|
|
auth: "none",
|
|
defaults: {},
|
|
});
|
|
expect(method("zapier")?.credentialFields).toBeUndefined();
|
|
});
|
|
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("requests only Hugging Face's MCP read scope", () =>
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0]
|
|
?.defaults?.scopesHint,
|
|
).toEqual(["read-mcp"]));
|
|
it("defaults every new connection action to allowed", () => {
|
|
for (const app of APP_DEFINITIONS)
|
|
for (const method of app.methods)
|
|
expect(recommendedDefaultsForApp(app, method.key)).toEqual({
|
|
access: "all_agents",
|
|
askFirstRiskLevels: [],
|
|
});
|
|
});
|
|
it("defaults explicit read/write capability groups to their write-capable method", () => {
|
|
const drive = APP_DEFINITIONS.find((app) => app.slug === "google-drive")!;
|
|
const gmail = APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
|
|
expect(getAvailableConnectionMethod(drive)?.key).toBe(
|
|
"customer-write-oauth",
|
|
);
|
|
expect(getAvailableConnectionMethod(gmail)?.key).toBe(
|
|
"customer-draft-oauth",
|
|
);
|
|
expect(
|
|
getRecommendedConnectionMethod(
|
|
drive.methods.filter((candidate) =>
|
|
candidate.ownershipModes.includes("customer"),
|
|
),
|
|
)?.key,
|
|
).toBe("customer-write-oauth");
|
|
expect(
|
|
getRecommendedConnectionMethod(
|
|
gmail.methods.filter((candidate) =>
|
|
[
|
|
"paperclip-read",
|
|
"customer-read-oauth",
|
|
"customer-draft-oauth",
|
|
].includes(candidate.key),
|
|
),
|
|
)?.key,
|
|
).toBe("paperclip-read");
|
|
expect(
|
|
getRecommendedConnectionMethod(
|
|
gmail.methods.filter(
|
|
(candidate) => candidate.capabilityProfile?.key === "draft",
|
|
),
|
|
)?.key,
|
|
).toBe("paperclip-draft");
|
|
expect(
|
|
getRecommendedConnectionMethod(
|
|
gmail.methods.filter(
|
|
(candidate) =>
|
|
candidate.capabilityProfile?.key === "draft" &&
|
|
candidate.ownershipModes.includes("customer"),
|
|
),
|
|
)?.key,
|
|
).toBe("customer-draft-oauth");
|
|
});
|
|
it("explains Google Workspace Developer Preview enrollment before connection", () => {
|
|
const googleWorkspaceMcpSlugs = [
|
|
"gmail",
|
|
"google-drive",
|
|
"google-docs",
|
|
"google-sheets",
|
|
"google-slides",
|
|
"google-calendar",
|
|
"google-chat",
|
|
"google-people",
|
|
"google-workspace-search",
|
|
];
|
|
for (const slug of googleWorkspaceMcpSlugs) {
|
|
const prerequisite = APP_DEFINITIONS.find(
|
|
(app) => app.slug === slug,
|
|
)?.setupPrerequisite;
|
|
expect(prerequisite?.actionUrl, slug).toBe(
|
|
"https://developers.google.com/workspace/preview",
|
|
);
|
|
expect(prerequisite?.description, slug).toContain(
|
|
"does not enable unrelated Paperclip customers",
|
|
);
|
|
expect(prerequisite?.steps?.join(" "), slug).toContain(
|
|
"final project-registration email",
|
|
);
|
|
}
|
|
});
|
|
it("withholds unverified and reserved providers from the app store without deleting their definitions", () => {
|
|
expect([...APP_STORE_HIDDEN_SLUGS].sort()).toEqual([
|
|
"beehiiv",
|
|
"bitly",
|
|
"brex",
|
|
"candid",
|
|
"coda",
|
|
"composio",
|
|
"context7",
|
|
"egnyte",
|
|
"embat",
|
|
"kernel",
|
|
"local-falcon",
|
|
"make",
|
|
"manufact",
|
|
"oreilly",
|
|
"planetscale",
|
|
"razorpay",
|
|
"sanity",
|
|
"similarweb",
|
|
"ticket-tailor",
|
|
"ticktick",
|
|
"xero",
|
|
]);
|
|
expect(APP_STORE_DEFINITIONS).toHaveLength(41);
|
|
const connectableSlugs = new Set(
|
|
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
|
|
);
|
|
const storeSlugs = new Set(
|
|
APP_STORE_DEFINITIONS.map((entry) => entry.slug),
|
|
);
|
|
for (const slug of APP_STORE_HIDDEN_SLUGS) {
|
|
expect(connectableSlugs.has(slug), slug).toBe(true);
|
|
expect(storeSlugs.has(slug), slug).toBe(false);
|
|
}
|
|
});
|
|
it("ships complete local branding provenance for all 41 store-visible providers", () => {
|
|
const uiPublic = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"../../../ui/public",
|
|
);
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(uiPublic, "brands/apps/manifest.json"), "utf8"),
|
|
) as {
|
|
providers: Array<{
|
|
slug: string;
|
|
catalogVisible: boolean;
|
|
localAsset: string;
|
|
darkAsset?: string;
|
|
officialSourceUrl: string;
|
|
upstreamAssetUrl: string;
|
|
assetType: "svg" | "png";
|
|
darkVariantRequired: boolean;
|
|
}>;
|
|
};
|
|
const visible = manifest.providers.filter((entry) => entry.catalogVisible);
|
|
expect(visible).toHaveLength(41);
|
|
expect(new Set(visible.map((entry) => entry.slug))).toHaveProperty(
|
|
"size",
|
|
41,
|
|
);
|
|
expect(new Set(visible.map((entry) => entry.localAsset))).toHaveProperty(
|
|
"size",
|
|
41,
|
|
);
|
|
expect(new Set(APP_STORE_DEFINITIONS.map((entry) => entry.slug))).toEqual(
|
|
new Set(visible.map((entry) => entry.slug)),
|
|
);
|
|
for (const app of APP_STORE_DEFINITIONS) {
|
|
const provenance = visible.find((entry) => entry.slug === app.slug)!;
|
|
expect(provenance).toBeTruthy();
|
|
expect(provenance.localAsset).toBe(app.branding.logoUrl);
|
|
expect(provenance.darkAsset).toBe(app.branding.darkLogoUrl);
|
|
expect(provenance.darkVariantRequired).toBe(
|
|
Boolean(provenance.darkAsset),
|
|
);
|
|
expect(new URL(provenance.officialSourceUrl).protocol).toBe("https:");
|
|
expect(new URL(provenance.upstreamAssetUrl).protocol).toBe("https:");
|
|
expect(provenance.localAsset).toMatch(/^\/brands\/apps\/.+\.(svg|png)$/);
|
|
expect(provenance.localAsset).not.toContain("google.com/s2/favicons");
|
|
const asset = fs.readFileSync(path.join(uiPublic, provenance.localAsset));
|
|
if (provenance.assetType === "png") {
|
|
expect(asset.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a");
|
|
expect(asset.readUInt32BE(16)).toBeGreaterThanOrEqual(128);
|
|
expect(asset.readUInt32BE(20)).toBeGreaterThanOrEqual(128);
|
|
} else {
|
|
const svg = asset.toString("utf8");
|
|
expect(svg).toMatch(/^<svg\b/);
|
|
expect(svg).not.toMatch(/<script|<foreignObject|\son[a-z]+\s*=/i);
|
|
}
|
|
if (provenance.darkAsset)
|
|
expect(fs.existsSync(path.join(uiPublic, provenance.darkAsset))).toBe(
|
|
true,
|
|
);
|
|
}
|
|
});
|
|
it("keeps every researched self-serve candidate implemented while blocked providers stay absent", () => {
|
|
const definitions = new Map(
|
|
CONNECTABLE_APP_DEFINITIONS.map((entry) => [entry.slug, entry]),
|
|
);
|
|
for (const candidate of SELF_SERVE_MCP_CANDIDATES)
|
|
expect(appSupportsCatalogSetup(definitions.get(candidate.slug))).toBe(
|
|
true,
|
|
);
|
|
for (const blocked of BLOCKED_MCP_PROVIDERS)
|
|
expect(definitions.has(blocked.slug)).toBe(false);
|
|
});
|
|
it("keeps all Google Workspace profiles aligned with their app, endpoint, scopes, ownership, risk, and write policy", () => {
|
|
expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS).toEqual(
|
|
GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS.map((entry) => entry.profile),
|
|
);
|
|
expect(Object.keys(GOOGLE_WORKSPACE_CONNECTOR_PROFILES)).toEqual([
|
|
...GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS,
|
|
]);
|
|
for (const expected of GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS) {
|
|
expect(
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES[expected.profile],
|
|
expected.profile,
|
|
).toEqual({
|
|
appSlug: expected.appSlug,
|
|
serverUrl: expected.serverUrl,
|
|
scopes: expected.scopes,
|
|
writeTools: expected.writeTools,
|
|
});
|
|
const app = APP_DEFINITIONS.find(
|
|
(candidate) => candidate.slug === expected.appSlug,
|
|
);
|
|
const managed = app?.methods.find(
|
|
(method) => method.connectorProfile === expected.profile,
|
|
);
|
|
expect(managed, expected.profile).toMatchObject({
|
|
auth: "oauth",
|
|
oauthStrategy: "paperclip_cloud_connector",
|
|
connectorProfile: expected.profile,
|
|
capabilityProfile: { key: expected.capability },
|
|
grantKinds: ["user", "organization"],
|
|
ownershipModes: ["platform_shared"],
|
|
defaults: {
|
|
serverUrl: expected.serverUrl,
|
|
scopesHint: expected.scopes,
|
|
},
|
|
riskTier: expected.riskTier,
|
|
});
|
|
expect(managed?.riskTier, `${expected.profile}:write-risk`).toBe(
|
|
expected.writeTools.length > 0 ? "S4" : "S3",
|
|
);
|
|
const customer = app?.methods.find(
|
|
(method) =>
|
|
method.auth === "oauth" &&
|
|
method.oauthStrategy === undefined &&
|
|
method.capabilityProfile?.key === expected.capability,
|
|
);
|
|
expect(customer, `${expected.profile}:customer-fallback`).toMatchObject({
|
|
grantKinds: ["user", "organization"],
|
|
ownershipModes: ["customer"],
|
|
defaults: {
|
|
serverUrl: expected.serverUrl,
|
|
scopesHint: expected.scopes,
|
|
},
|
|
riskTier: expected.riskTier,
|
|
});
|
|
}
|
|
});
|
|
it("configures Shopify's current UCP and compatibility MCP methods without OAuth", () => {
|
|
const shopify = APP_DEFINITIONS.find((app) => app.slug === "shopify");
|
|
expect(shopify?.methods.map((method) => method.key)).toEqual([
|
|
"ucp-commerce",
|
|
"storefront-mcp",
|
|
]);
|
|
const ucp = shopify?.methods[0];
|
|
const compatibility = shopify?.methods[1];
|
|
expect(ucp).toMatchObject({
|
|
auth: "none",
|
|
defaults: {
|
|
serverUrlTemplate: "https://{storeDomain}/api/ucp/mcp",
|
|
toolArgumentDefaults: {
|
|
meta: {
|
|
"ucp-agent": {
|
|
profile:
|
|
"https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
tenantFields: [
|
|
expect.objectContaining({ key: "storeDomain", required: true }),
|
|
],
|
|
});
|
|
expect(compatibility).toMatchObject({
|
|
auth: "none",
|
|
defaults: { serverUrlTemplate: "https://{storeDomain}/api/mcp" },
|
|
});
|
|
expect(
|
|
resolveConnectionMethodServerUrl(ucp!, {
|
|
storeDomain: "paperclip-demo.myshopify.com",
|
|
}),
|
|
).toBe("https://paperclip-demo.myshopify.com/api/ucp/mcp");
|
|
expect(
|
|
resolveConnectionMethodServerUrl(compatibility!, {
|
|
storeDomain: "paperclip-demo.myshopify.com",
|
|
}),
|
|
).toBe("https://paperclip-demo.myshopify.com/api/mcp");
|
|
expect(resolveConnectionMethodServerUrl(ucp!, {})).toBeNull();
|
|
expect(shopify?.setupPrerequisite).toMatchObject({
|
|
title: "Launch the storefront before connecting",
|
|
actionUrl: "https://admin.shopify.com/",
|
|
});
|
|
expect(shopify?.setupPrerequisite?.steps?.join(" ")).toContain(
|
|
"Storefront visibility to Public",
|
|
);
|
|
});
|
|
it("offers PostHog OAuth and API-key methods with zero-config defaults and advanced narrowing", () => {
|
|
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 ?? []) {
|
|
const projectField = method.tenantFields?.find(
|
|
(field) => field.key === "projectId",
|
|
);
|
|
expect(method.riskTier).toBe("S3");
|
|
expect(
|
|
method.tenantFields?.find((field) => field.key === "readOnly"),
|
|
).toMatchObject({ defaultValue: false, advanced: true });
|
|
expect(projectField).toMatchObject({
|
|
advanced: true,
|
|
transport: { location: "header", name: "x-posthog-project-id" },
|
|
});
|
|
expect(projectField?.required).not.toBe(true);
|
|
expect(
|
|
method.tenantFields
|
|
?.filter((field) => field.advanced)
|
|
.map((field) => field.key),
|
|
).toEqual(["projectId", "readOnly", "features", "tools"]);
|
|
expect(
|
|
method.tenantFields?.find((field) => field.key === "mode"),
|
|
).toMatchObject({
|
|
hidden: true,
|
|
defaultValue: "tools",
|
|
transport: { location: "query", name: "mode" },
|
|
});
|
|
expect(method.configRequirements).toBeUndefined();
|
|
expect(method.requiredResourceFilters).toBeUndefined();
|
|
expect(method.guidanceMd).toContain("optional advanced controls");
|
|
}
|
|
});
|
|
it("requires only reviewed provider or safety-boundary configuration on the default path", () => {
|
|
const required = APP_DEFINITIONS.flatMap((app) =>
|
|
app.methods.flatMap((method) =>
|
|
[...(method.tenantFields ?? []), ...(method.extensionFields ?? [])]
|
|
.filter(
|
|
(field) =>
|
|
field.required && field.advanced !== true && !field.hidden,
|
|
)
|
|
.map((field) => `${app.slug}:${method.key}:${field.key}`),
|
|
),
|
|
).sort();
|
|
expect(required).toEqual([
|
|
"clickhouse:mcp-oauth:serviceId",
|
|
"shopify:storefront-mcp:storeDomain",
|
|
"shopify:ucp-commerce:storeDomain",
|
|
"supabase:mcp-api-key:projectRef",
|
|
"supabase:mcp-oauth:projectRef",
|
|
]);
|
|
});
|
|
it("limits Vercel Connect setup to the reviewed pilot methods", () => {
|
|
const reviewed = APP_DEFINITIONS.flatMap((app) =>
|
|
app.methods.flatMap((method) =>
|
|
method.credentialSources?.vercelConnect
|
|
? [
|
|
{
|
|
slug: app.slug,
|
|
key: method.key,
|
|
review: method.credentialSources.vercelConnect,
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
);
|
|
expect(reviewed.map(({ slug, key }) => `${slug}:${key}`).sort()).toEqual([
|
|
"linear:mcp-oauth",
|
|
"notion:mcp-oauth",
|
|
"posthog:mcp-api-key",
|
|
"posthog:mcp-oauth",
|
|
]);
|
|
expect(
|
|
reviewed.find(({ slug }) => slug === "linear")?.review,
|
|
).toMatchObject({
|
|
services: ["linear"],
|
|
principalModes: ["user"],
|
|
scopes: ["read", "write"],
|
|
header: { name: "Authorization", prefix: "Bearer " },
|
|
});
|
|
expect(
|
|
reviewed.find(
|
|
({ slug, key }) => slug === "posthog" && key === "mcp-oauth",
|
|
)?.review.services,
|
|
).toEqual(["posthog", "mcp.posthog.com/mcp"]);
|
|
expect(
|
|
reviewed.find(
|
|
({ slug, key }) => slug === "posthog" && key === "mcp-api-key",
|
|
)?.review.principalModes,
|
|
).toEqual(["app"]);
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "vercel")?.availability
|
|
?.available,
|
|
).toBe(false);
|
|
});
|
|
it("enforces method and field invariants", () => {
|
|
for (const app of APP_DEFINITIONS)
|
|
for (const method of app.methods) {
|
|
if (
|
|
method.auth === "api_key" &&
|
|
(method.purpose ?? "tool") !== "channel"
|
|
)
|
|
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();
|
|
}
|
|
});
|
|
});
|