mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps connections give agents controlled access to external services. > - Daytona provides sandbox, file, command, Git, and account APIs. > - Operators need to assign this access without exposing a server credential. > - A small task-specific tool list cannot serve general Daytona work. > - This pull request adds the published HTTP actions through the existing connection gateway. > - Operators keep control of identity, agent access, and each action. ## Linked Issues or Issue Description **Problem or motivation** Paperclip has a Daytona sandbox provider for its own execution infrastructure. It lacks a normal Apps connection for agents to manage an operator's Daytona account. Reusing the server's provider credential would bypass normal connection ownership and action permissions. **Proposed solution** Add a Daytona connection with personal or shared vault credentials. Generate its action catalog from the official platform and Toolbox API specifications. Use the existing run-bound gateway, grants, action policies, approvals, revocation, and audit paths. Sandbox IDs come from Daytona; tools need no Paperclip task or lease ID. **Alternatives considered** A four-action diagnostic subset cannot support general provider work. Launching the provider CLI would expose host filesystem behavior. Native OAuth is not included because a supported third-party registration path was not verified. The native method uses an organization API key. Existing Composio setup is a separate possible OAuth route and is not qualified by this change. **Roadmap alignment** This extends the existing Apps and Connections provider coverage in `ROADMAP.md`. It uses that system's credential and permission model. GitHub searches found no existing Daytona Apps connector PR or issue. ## What Changed - Add the Daytona catalog entry, provider source override, official theme logos, permission review, and setup documentation. - Generate 274 HTTP actions: 169 platform and 105 Toolbox operations. Record two WebSocket upgrades separately; do not advertise them as HTTP tools. Record source URLs and hashes. - Add provider-focused execution for sandboxes, snapshots, volumes, files, Git, commands, computer use, and account resources. Provider authority still limits which endpoints accept an organization key. - Validate action arguments. Resolve Toolbox targets through the fixed platform API. Check sandbox identity and organization before using an approved Daytona proxy. Keep normal guarded network requests and vault resolution. - Transfer file bytes without reading or writing host files. Share one caller deadline across prerequisite reads, dispatch, and response reading. Bound requests and responses. Do not automatically retry writes with unknown outcomes. Redact the selected credential from results and errors. - Classify credential access, access changes, interactive input, command execution, file permissions, and deletion (including TTL and auto-delete) as destructive actions. Keep new and changed actions under catalog review. - Upgrade saved catalogs and retire the earlier diagnostic names. Hide retired actions from the UI and agent discovery even when provider refresh fails. Preserve existing action choices and historical records. Reject stale same-name contracts during agent discovery and dispatch before a board refresh. - Add database-backed gateway and catalog-upgrade regressions, schema generator tests, a browser setup check, and both capability map updates. ## Verification - Passed on the review-fix head: 57 focused Vitest cases (18 Daytona database/client cases, five HTTP boundary cases, and 34 shared catalog cases), four generator tests, `pnpm -r typecheck`, and `pnpm build`. The five HTTP cases use a real local server through the pinned production transport. The normal browser setup check and 17 generator/artwork cases passed on the initial PR head; those UI and artwork paths are unchanged. - Passed on this branch: brand asset validation, token gates, map structure checks, and `git diff --check`. All latest-head GitHub checks pass on `ea979ccb3a64dc9508807823540f935c4316ea35`. Apex is 5/5, with all five findings addressed and zero unresolved threads. The branch has no merge conflicts. - The initial catalog-generation command could not find the external research corpus. The supported `--definitions-only` mode regenerated the definitions successfully. Unrelated generated drift was excluded. - An earlier full local Vitest attempt on the preview branch failed when bundled PostgreSQL could not start on macOS. After replay onto current master, all three unchanged event-sequencing cases pass with bundled PostgreSQL; the earlier startup cause remains uncertain. - The first full-suite command on this branch used my external test database override. Chat fixtures require a current migrated schema and failed because `agents.lifecycle_state` was absent in that disposable database. A focused comparison reproduces failure with the override and passes with normal setup. That attempt was stopped, and the full suite restarted without the override. Focused Daytona cases use their own migrated PostgreSQL 16 databases. - The clean local full-suite attempt recorded embedded PostgreSQL startup failures in two unchanged native-runtime cases and two failures in unchanged Git fixtures. It was stopped after these failures were isolated; no full local suite pass is claimed. The 15 matching native-runtime cases pass separately. The Git cases fail with the local `init.defaultBranch=main` setting and pass with `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=init.defaultBranch GIT_CONFIG_VALUE_0=master` limited to the test command. No global Git setting was changed. Full Linux CI is green. - Fixtures use real database persistence and run-bound gateway sessions with provider responses replaced by fixtures. They cover personal/shared vault ownership, reads and writes, file bytes, Off policies, revocation, organization mismatch, untrusted proxy rejection, output bounds, saved catalog upgrades, failed refresh, and permission preservation. - The first Apex review found five valid issues: multipart transport compatibility, destructive classification, caller deadlines, audit filters, and stale saved contracts. All five were fixed with regressions before the second review. A typecheck then found the nullable saved schema hash; admission now accepts that stored type and rejects null as stale. - The browser check covers setup controls without submitting a provider key. No real Daytona account, provider write, native OAuth flow, or agent model run is qualified. Live provider read/write proof remains pending a test account. ## Risks - Daytona keys have broad Toolbox authority, including keys with no selected resource scopes. Only assign this connection to trusted agents and review its action permissions. - Remote writes or commands can continue after a timeout. Calls do not automatically retry an uncertain outcome. - The bridge targets Daytona Cloud. Proxy hosts are restricted; an unrecognized future regional host fails closed. - Files and responses have an 8 MiB bound. Each action has one caller deadline, capped at 30 seconds across all provider requests and response reading. Interactive WebSocket sessions are not exposed as HTTP actions. - The generated JSON snapshot exceeds 1,000 lines because it holds the provider input contracts. Generator logic and execution code remain in separate small modules. Updates require explicit regeneration and catalog review. - No database migration or lockfile change is required. ## Model Used OpenAI Codex, GPT-6. The runtime identifies the model family but does not expose the exact backend model ID or context window. Used reasoning, repository inspection, code editing, shell tools, database verification, and browser tests. ## 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>
1412 lines
53 KiB
TypeScript
1412 lines
53 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,
|
|
getAvailableConnectionMethods,
|
|
getAppDefinitionForUrl,
|
|
getConnectableAppDefinition,
|
|
getAppStoreDefinition,
|
|
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 { aiConnectionRouterAppDefinition } from "./ai-connection-router.js";
|
|
import { appDefinitionSchema, 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: "gmail.archive",
|
|
appSlug: "gmail",
|
|
serverUrl: "https://gmailmcp.googleapis.com/mcp/v1",
|
|
capability: "archive",
|
|
riskTier: "S4",
|
|
scopes: [googleScope("gmail.readonly"), googleScope("gmail.modify")],
|
|
writeTools: ["unlabel_thread"],
|
|
},
|
|
{
|
|
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("documents.readonly")],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "docs.write",
|
|
appSlug: "google-docs",
|
|
serverUrl: "https://docsmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [googleScope("documents")],
|
|
writeTools: ["update_doc"],
|
|
},
|
|
{
|
|
profile: "sheets.read",
|
|
appSlug: "google-sheets",
|
|
serverUrl: "https://sheetsmcp.googleapis.com/mcp/v1",
|
|
capability: "read",
|
|
riskTier: "S3",
|
|
scopes: [googleScope("spreadsheets.readonly")],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "sheets.write",
|
|
appSlug: "google-sheets",
|
|
serverUrl: "https://sheetsmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [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("presentations.readonly")],
|
|
writeTools: [],
|
|
},
|
|
{
|
|
profile: "slides.write",
|
|
appSlug: "google-slides",
|
|
serverUrl: "https://slidesmcp.googleapis.com/mcp/v1",
|
|
capability: "write",
|
|
riskTier: "S4",
|
|
scopes: [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"),
|
|
],
|
|
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.messages.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.messages.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" | "archive";
|
|
riskTier: "S3" | "S4";
|
|
scopes: readonly string[];
|
|
writeTools: readonly string[];
|
|
}>;
|
|
describe("AppDefinition catalog", () => {
|
|
it("namespaces Browser Use's hosted service as browser-use-cloud", () => {
|
|
expect(CONNECTABLE_APP_DEFINITIONS.find(app => app.slug === "browser-use-cloud"))
|
|
.toMatchObject({ name: "Browser Use Cloud", methods: [{ key: "cloud-v4", transport: "rest_api" }] });
|
|
expect(APP_DEFINITIONS.some(app => app.slug === "browser-use")).toBe(false);
|
|
expect(getAppDefinitionForUrl("https://cloud.browser-use.com/agents")?.slug).toBe("browser-use-cloud");
|
|
});
|
|
it("offers Anthropic runtime authentication without the unsupported REST tool method", () => {
|
|
const anthropic = APP_DEFINITIONS.find((app) => app.slug === "anthropic")!;
|
|
expect(anthropic.methods.map((method) => method.key)).toEqual(["ai-subscription", "ai-api_key"]);
|
|
expect(anthropic.methods.every((method) => method.purpose === "ai" && method.transport === "runtime_auth")).toBe(true);
|
|
expect(getAvailableConnectionMethod(anthropic, "api-key")).toBeNull();
|
|
});
|
|
|
|
it("validates all Wave 1 definitions", () =>
|
|
expect(() => appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow());
|
|
it("lists model providers as regular tagged catalog entries", () => {
|
|
const slugs = ["openai", "anthropic", "openrouter", "xai", "google", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"];
|
|
expect(APP_STORE_DEFINITIONS.filter(app => app.tags?.includes("model-provider")).map(app => app.slug).sort()).toEqual(slugs.sort());
|
|
for (const slug of slugs) {
|
|
const app = APP_STORE_DEFINITIONS.find(app => app.slug === slug)!;
|
|
expect(app.methods.some(method => method.purpose === "ai" && method.transport === "runtime_auth")).toBe(true);
|
|
}
|
|
});
|
|
|
|
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(63);
|
|
expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([
|
|
"g2",
|
|
"vercel",
|
|
"zomato",
|
|
"monday",
|
|
]);
|
|
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(getAppStoreDefinition(entry.slug)).toBeNull();
|
|
});
|
|
it("separates GitHub tools from the review bot without changing the provider identity", () => {
|
|
const github = getConnectableAppDefinition("github")!;
|
|
const bot = getConnectableAppDefinition("github-code-review-bot")!;
|
|
expect(github.methods.map((method) => method.key)).toEqual(["managed", "mcp-key"]);
|
|
expect(github.methods.every((method) => method.purpose === "tool")).toBe(true);
|
|
expect(bot.name).toBe("GitHub Code Review Bot");
|
|
expect(bot.methods).toHaveLength(1);
|
|
expect(bot.methods[0]).toMatchObject({ provider: "github", purpose: "channel", transport: "chat_sdk" });
|
|
expect(bot.branding).toEqual(github.branding);
|
|
expect(bot.urlPatterns).toEqual([]);
|
|
});
|
|
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-code-review-bot": {
|
|
credentials: ["appId", "privateKey"],
|
|
publicFields: ["appId"],
|
|
resources: ["organization", "repository"],
|
|
tool: false,
|
|
},
|
|
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 === "github-code-review-bot" ? "github" : 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-code-review-bot")?.guidanceMd).toContain("issue_comment");
|
|
expect(channel("discord")?.guidanceMd).toContain("Message Content intent");
|
|
expect(channel("discord")?.guidanceMd).toContain("Discord thread");
|
|
expect(channel("github-code-review-bot")?.guidanceMd).toContain("pull_request");
|
|
expect(channel("github-code-review-bot")?.guidanceMd).toContain(
|
|
"pull_request_review_comment",
|
|
);
|
|
expect(channel("github-code-review-bot")?.guidanceMd).toContain(
|
|
"installation_repositories",
|
|
);
|
|
expect(channel("github-code-review-bot")?.guidanceMd).toContain(
|
|
"Generate the webhook secret in Paperclip",
|
|
);
|
|
expect(channel("github-code-review-bot")?.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 67 researched MCP providers", () => {
|
|
// Ledger-wide date reflects the last full re-verification (2026-08-26);
|
|
// later provider additions carry their own research evidence, but
|
|
// bumping the shared date would overstate freshness for the other providers.
|
|
expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26");
|
|
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(67);
|
|
expect(
|
|
new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)),
|
|
).toHaveProperty("size", 67);
|
|
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("offers Fireflies browser sign-in and a vaulted bearer key on the same official MCP endpoint", () => {
|
|
const app = APP_STORE_DEFINITIONS.find((entry) => entry.slug === "fireflies")!;
|
|
expect(getAppDefinitionForUrl("https://api.fireflies.ai/mcp")?.slug).toBe("fireflies");
|
|
expect(app.methods.map((method) => method.key)).toEqual(["mcp-oauth", "mcp-api-key"]);
|
|
expect(app.methods[0]).toMatchObject({
|
|
transport: "mcp_remote", auth: "oauth", ownershipModes: ["dcr"],
|
|
defaults: { serverUrl: "https://api.fireflies.ai/mcp", scopesHint: ["email", "profile"] },
|
|
});
|
|
expect(app.methods[1]).toMatchObject({
|
|
auth: "api_key", defaults: { serverUrl: "https://api.fireflies.ai/mcp" },
|
|
credentialFields: [{ key: "authorization", secret: true, type: "password", required: true }],
|
|
keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " },
|
|
});
|
|
});
|
|
|
|
it("supports organization tokens and OAuth for the official read-only Enterpret MCP", () => {
|
|
const app = CONNECTABLE_APP_DEFINITIONS.find(
|
|
(entry) => entry.slug === "enterpret",
|
|
)!;
|
|
expect(
|
|
getAppDefinitionForUrl("https://wisdom-api.enterpret.com/server/mcp")
|
|
?.slug,
|
|
).toBe("enterpret");
|
|
// Both methods target the official read-only MCP, not the beta Agent MCP.
|
|
expect(app.methods.map((method) => method.key)).toEqual([
|
|
"mcp-api-key",
|
|
"mcp-oauth",
|
|
]);
|
|
expect(app.redirectConstraints).toBe("https-or-loopback-http");
|
|
expect(APP_STORE_HIDDEN_SLUGS.has("enterpret")).toBe(false);
|
|
expect(app.availability?.available).not.toBe(false);
|
|
expect(app.ownershipAvailability?.dcr).not.toBe(false);
|
|
expect(getAvailableConnectionMethods(app).map((method) => method.key)).toEqual([
|
|
"mcp-api-key",
|
|
"mcp-oauth",
|
|
]);
|
|
expect(getAvailableConnectionMethod(app)?.key).toBe("mcp-api-key");
|
|
expect(getAvailableConnectionMethod(app, "mcp-oauth")?.key).toBe("mcp-oauth");
|
|
expect(app.methods[0]).toMatchObject({
|
|
transport: "mcp_remote",
|
|
auth: "api_key",
|
|
ownershipModes: ["customer"],
|
|
grantKinds: ["organization"],
|
|
riskTier: "S3",
|
|
defaults: { serverUrl: "https://wisdom-api.enterpret.com/server/mcp" },
|
|
credentialFields: [
|
|
{ key: "authorization", type: "password", required: true, secret: true },
|
|
],
|
|
keyPlacement: {
|
|
location: "header",
|
|
name: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
});
|
|
expect(app.methods[1]).toMatchObject({
|
|
transport: "mcp_remote",
|
|
auth: "oauth",
|
|
// Enterpret documents no customer-registered OAuth app, only RFC 7591.
|
|
ownershipModes: ["dcr"],
|
|
grantKinds: ["user"],
|
|
riskTier: "S3",
|
|
defaults: {
|
|
serverUrl: "https://wisdom-api.enterpret.com/server/mcp",
|
|
// Narrower than the "mcp:read mcp:write" the 401 challenge advertises.
|
|
scopesHint: ["mcp:read"],
|
|
},
|
|
});
|
|
// RFC 9728 -> RFC 8414 discovery resolves from the challenge, so shipping a
|
|
// complete endpoint pair would suppress discovery permanently.
|
|
expect(app.methods[1].defaults?.authorizationEndpoint).toBeUndefined();
|
|
expect(app.methods[1].defaults?.tokenEndpoint).toBeUndefined();
|
|
expect(app.methods[1].label).toBe("Sign in with Enterpret");
|
|
expect(app.methods[1].warnings?.some((w) => /broader OAuth scopes/i.test(w))).toBe(
|
|
true,
|
|
);
|
|
// The definition records the placement of a credential, never a value.
|
|
const serialized = JSON.stringify(app);
|
|
expect(serialized).not.toMatch(/eyJ[A-Za-z0-9_-]{8,}/);
|
|
// "Bearer " may appear only as the header prefix, never trailed by a value.
|
|
expect(serialized.match(/Bearer[^"]*/g)).toEqual(["Bearer "]);
|
|
for (const method of app.methods)
|
|
for (const credentialField of method.credentialFields ?? [])
|
|
expect(credentialField).not.toHaveProperty("defaultValue");
|
|
});
|
|
|
|
it("exposes Make with its reviewed browser OAuth and MCP token alternatives", () => {
|
|
const app = APP_STORE_DEFINITIONS.find((entry) => entry.slug === "make");
|
|
expect(app).toBeTruthy();
|
|
expect(APP_STORE_HIDDEN_SLUGS.has("make")).toBe(false);
|
|
expect(CONNECTABLE_APP_DEFINITIONS.some((entry) => entry.slug === "make")).toBe(true);
|
|
expect(getAvailableConnectionMethods(app!).map((method) => method.key)).toEqual([
|
|
"mcp-oauth",
|
|
"mcp-api-token",
|
|
]);
|
|
expect(app!.methods[0]).toMatchObject({
|
|
transport: "mcp_remote",
|
|
auth: "oauth",
|
|
ownershipModes: ["dcr"],
|
|
defaults: { serverUrl: "https://mcp.make.com" },
|
|
});
|
|
expect(app!.methods[1]).toMatchObject({
|
|
transport: "mcp_remote",
|
|
auth: "api_key",
|
|
ownershipModes: ["customer"],
|
|
defaults: { serverUrlTemplate: "https://{zone}/mcp" },
|
|
keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " },
|
|
});
|
|
});
|
|
|
|
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")).toMatchObject({
|
|
key: "managed", ownershipModes: ["platform_shared"], connectorProfile: "asana.mcp",
|
|
defaults: { serverUrl: "https://mcp.asana.com/v2/mcp", scopesHint: ["default"] },
|
|
});
|
|
expect(APP_DEFINITIONS.find((app) => app.slug === "asana")?.methods[1]).toMatchObject({
|
|
key: "mcp-own-oauth", ownershipModes: ["customer"], oauthClientSecretRequired: true,
|
|
defaults: { discoveryUrl: "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" },
|
|
consoleLinks: { register: "https://app.asana.com/0/my-apps" },
|
|
});
|
|
expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]);
|
|
expect(method("sentry")).toMatchObject({
|
|
key: "mcp-oauth",
|
|
defaults: {
|
|
serverUrl: "https://mcp.sentry.dev/mcp",
|
|
discoveryUrl: "https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp",
|
|
},
|
|
});
|
|
expect(method("zapier")).toMatchObject({
|
|
key: "generated-url",
|
|
auth: "none",
|
|
defaults: {},
|
|
});
|
|
expect(method("zapier")?.credentialFields).toBeUndefined();
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "youcom")?.methods.map(
|
|
(candidate) => candidate.key,
|
|
),
|
|
).toEqual(["mcp-oauth", "mcp-api-key", "mcp-free"]);
|
|
expect(method("youcom")?.defaults?.serverUrl).toBe("https://api.you.com/mcp");
|
|
expect(method("youcom", "mcp-api-key")).toMatchObject({
|
|
auth: "api_key",
|
|
keyPlacement: {
|
|
location: "header",
|
|
name: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
});
|
|
expect(method("youcom", "mcp-free")).toMatchObject({
|
|
auth: "none",
|
|
defaults: { serverUrl: "https://api.you.com/mcp?profile=free" },
|
|
});
|
|
expect(method("youcom", "mcp-free")?.credentialFields).toBeUndefined();
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "telem")?.methods.map(
|
|
(candidate) => candidate.key,
|
|
),
|
|
).toEqual(["mcp-api-key"]);
|
|
expect(method("telem")).toMatchObject({
|
|
auth: "api_key",
|
|
defaults: { serverUrl: "https://mcp.telem.ai/mcp" },
|
|
keyPlacement: {
|
|
location: "header",
|
|
name: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
consoleLinks: { keys: "https://app.telem.ai" },
|
|
});
|
|
expect(
|
|
method("telem")?.tenantFields?.map((field) => [
|
|
field.key,
|
|
field.required ?? false,
|
|
field.transport?.location,
|
|
field.transport?.name,
|
|
]),
|
|
).toEqual([
|
|
["autoRouting", false, "header", "X-Telem-Auto-Routing"],
|
|
["tier", false, "header", "X-Telem-Tier"],
|
|
["providersInclude", false, "header", "X-Telem-Providers-Include"],
|
|
["providersExclude", false, "header", "X-Telem-Providers-Exclude"],
|
|
]);
|
|
});
|
|
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",
|
|
scopesHint: ["default"],
|
|
});
|
|
});
|
|
it("preserves required Linear OAuth scopes", () =>
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "linear")?.methods[0]?.defaults
|
|
?.scopesHint,
|
|
).toEqual(["read", "write"]));
|
|
it("limits Ask-first defaults to reviewed high-risk connection methods", () => {
|
|
for (const app of APP_DEFINITIONS)
|
|
for (const method of app.methods) {
|
|
if (app.slug === "enterpret" || (app.slug === "gmail" && method.capabilityProfile?.key === "archive")) {
|
|
expect(recommendedDefaultsForApp(app, method.key)).toEqual({
|
|
access: "all_agents",
|
|
askFirstRiskLevels: ["write", "destructive"],
|
|
});
|
|
continue;
|
|
}
|
|
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(gmail.methods.filter((method) => method.capabilityProfile?.key === "archive")
|
|
.map((method) => method.key)).toEqual(["paperclip-archive", "customer-archive-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("customer-draft-oauth");
|
|
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",
|
|
"brex",
|
|
"candid",
|
|
"clickup",
|
|
"coda",
|
|
"egnyte",
|
|
"embat",
|
|
"kernel",
|
|
"klaviyo",
|
|
"local-falcon",
|
|
"manufact",
|
|
"monday",
|
|
"oreilly",
|
|
"planetscale",
|
|
"razorpay",
|
|
"sanity",
|
|
"similarweb",
|
|
"ticket-tailor",
|
|
"ticktick",
|
|
"xero",
|
|
]);
|
|
expect(APP_STORE_DEFINITIONS).toHaveLength(81);
|
|
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 matching local artwork for every store-visible provider", () => {
|
|
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;
|
|
}>;
|
|
};
|
|
const visible = manifest.providers.filter((entry) => entry.catalogVisible);
|
|
expect(visible).toHaveLength(APP_STORE_DEFINITIONS.length);
|
|
expect(new Set(visible.map((entry) => entry.slug))).toHaveProperty(
|
|
"size",
|
|
visible.length,
|
|
);
|
|
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.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.localAsset.endsWith(".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.trimStart()).toMatch(/^(?:<\?xml[^?]*\?>\s*)?<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 out of Browse", () => {
|
|
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(getAppStoreDefinition(blocked.slug)).toBeNull();
|
|
});
|
|
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("connects Neon's hosted server with optional project pinning and read-only mode", () => {
|
|
const neon = APP_DEFINITIONS.find((app) => app.slug === "neon")!;
|
|
expect(neon).toMatchObject({
|
|
name: "Neon",
|
|
categories: ["data"],
|
|
urlPatterns: ["https://mcp.neon.tech/*"],
|
|
docsUrl: "https://neon.com/docs/ai/neon-mcp-server",
|
|
redirectConstraints: "https-or-loopback-http",
|
|
branding: { logoUrl: "/brands/apps/neon.png" },
|
|
});
|
|
expect(neon.branding.darkLogoUrl).toBeUndefined();
|
|
expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "neon")).toBe(true);
|
|
expect(neon.methods.map((candidate) => candidate.key)).toEqual([
|
|
"mcp-oauth",
|
|
"mcp-api-key",
|
|
]);
|
|
const [oauth, apiKey] = neon.methods;
|
|
expect(oauth).toMatchObject({
|
|
auth: "oauth",
|
|
ownershipModes: ["dcr"],
|
|
riskTier: "S4",
|
|
requiredResourceFilters: ["project"],
|
|
defaults: {
|
|
serverUrl: "https://mcp.neon.tech/mcp",
|
|
scopesHint: ["read", "write"],
|
|
},
|
|
});
|
|
expect(apiKey).toMatchObject({
|
|
auth: "api_key",
|
|
ownershipModes: ["customer"],
|
|
riskTier: "S4",
|
|
defaults: { serverUrl: "https://mcp.neon.tech/mcp" },
|
|
keyPlacement: {
|
|
location: "header",
|
|
name: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
consoleLinks: {
|
|
keys: "https://console.neon.tech/app/settings/api-keys",
|
|
},
|
|
});
|
|
expect(apiKey!.credentialFields).toEqual([
|
|
expect.objectContaining({
|
|
key: "authorization",
|
|
type: "password",
|
|
secret: true,
|
|
required: true,
|
|
}),
|
|
]);
|
|
for (const method of neon.methods) {
|
|
// Nothing is required on the default path: both narrowing controls are
|
|
// optional and folded under Advanced, and the provider enforces them.
|
|
expect(method.tenantFields?.map((field) => field.key)).toEqual([
|
|
"projectId",
|
|
"readOnly",
|
|
]);
|
|
expect(method.tenantFields?.every((field) => field.advanced && !field.required)).toBe(true);
|
|
expect(method.tenantFields?.[0]).toMatchObject({
|
|
type: "text",
|
|
validation: { pattern: "^[a-z0-9-]+$", maxLength: 64 },
|
|
transport: { location: "query", name: "projectId" },
|
|
});
|
|
expect(method.tenantFields?.[1]).toMatchObject({
|
|
type: "checkbox",
|
|
defaultValue: false,
|
|
transport: {
|
|
location: "query",
|
|
name: "readonly",
|
|
format: "boolean",
|
|
omitFalse: true,
|
|
},
|
|
});
|
|
expect(method.warnings?.length).toBe(2);
|
|
}
|
|
});
|
|
it("connects Gauge's hosted server with browser sign-in or an organization API key", () => {
|
|
const gauge = APP_DEFINITIONS.find((app) => app.slug === "gauge")!;
|
|
expect(gauge).toMatchObject({
|
|
name: "Gauge",
|
|
categories: ["analytics"],
|
|
urlPatterns: ["https://app.withgauge.com/*"],
|
|
docsUrl: "https://docs.withgauge.com/help/mcp",
|
|
branding: { logoUrl: "/brands/apps/gauge.png" },
|
|
redirectConstraints: "https-or-loopback-http",
|
|
});
|
|
expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "gauge")).toBe(true);
|
|
expect(gauge.methods.map((method) => method.key)).toEqual(["mcp-oauth", "mcp-api-key"]);
|
|
const [oauth, apiKey] = gauge.methods;
|
|
expect(oauth).toMatchObject({
|
|
transport: "mcp_remote",
|
|
auth: "oauth",
|
|
ownershipModes: ["dcr"],
|
|
riskTier: "S3",
|
|
// Gauge advertises identity scopes too; only the reviewed MCP scope is
|
|
// requested, and the organization is chosen on Gauge's consent screen.
|
|
defaults: { serverUrl: "https://app.withgauge.com/mcp", scopesHint: ["mcp"] },
|
|
});
|
|
expect(apiKey).toMatchObject({
|
|
transport: "mcp_remote",
|
|
auth: "api_key",
|
|
ownershipModes: ["customer"],
|
|
riskTier: "S3",
|
|
defaults: { serverUrl: "https://app.withgauge.com/mcp" },
|
|
keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " },
|
|
});
|
|
expect(apiKey!.credentialFields).toEqual([
|
|
expect.objectContaining({ key: "authorization", type: "password", secret: true, required: true }),
|
|
]);
|
|
// The key form renders credential helper text, not method warnings, so the
|
|
// publish advice must live in the helper.
|
|
expect(apiKey!.credentialFields?.[0]?.helperMd).toContain("set publish actions to Ask first");
|
|
for (const method of gauge.methods) {
|
|
// One shared profile: no capability picker, but the access step shows
|
|
// its description (with the publish warning) before sign-in.
|
|
expect(method.capabilityProfile).toMatchObject({
|
|
key: "write",
|
|
description: expect.stringContaining("publish to your connected CMS"),
|
|
});
|
|
expect(method.tenantFields ?? []).toEqual([]);
|
|
expect(method.warnings).toEqual([
|
|
expect.stringContaining("one Gauge organization"),
|
|
expect.stringContaining("publish to your connected CMS"),
|
|
]);
|
|
}
|
|
});
|
|
it("connects Superagent's hosted server with an organization API key only", () => {
|
|
const superagent = APP_DEFINITIONS.find((app) => app.slug === "superagent")!;
|
|
expect(superagent).toMatchObject({
|
|
name: "Superagent",
|
|
categories: ["developer"],
|
|
urlPatterns: ["https://www.superagent.sh/*"],
|
|
docsUrl: "https://www.superagent.sh/docs/mcp",
|
|
branding: { logoUrl: "/brands/apps/superagent.png" },
|
|
});
|
|
// The hosted server publishes no OAuth authorization-server metadata, so
|
|
// there is no browser sign-in method and no OAuth redirect constraint.
|
|
expect(superagent.redirectConstraints).toBeUndefined();
|
|
expect(superagent.branding.darkLogoUrl).toBeUndefined();
|
|
expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "superagent")).toBe(true);
|
|
expect(superagent.methods).toHaveLength(1);
|
|
const [apiKey] = superagent.methods;
|
|
expect(apiKey).toMatchObject({
|
|
key: "mcp-api-key",
|
|
transport: "mcp_remote",
|
|
auth: "api_key",
|
|
ownershipModes: ["customer"],
|
|
riskTier: "S4",
|
|
// The bare superagent.sh domain redirects, and some clients drop POST
|
|
// bodies on redirect, so the www host is pinned.
|
|
defaults: { serverUrl: "https://www.superagent.sh/mcp" },
|
|
keyPlacement: {
|
|
location: "header",
|
|
name: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
consoleLinks: {
|
|
keys: "https://www.superagent.sh/app/settings#api-keys",
|
|
docs: "https://www.superagent.sh/docs/mcp",
|
|
},
|
|
});
|
|
expect(apiKey!.tenantFields ?? []).toEqual([]);
|
|
expect(apiKey!.credentialFields).toEqual([
|
|
expect.objectContaining({
|
|
key: "authorization",
|
|
type: "password",
|
|
placeholder: "sk_live_...",
|
|
secret: true,
|
|
required: true,
|
|
}),
|
|
]);
|
|
expect(apiKey!.credentialFields?.[0]?.helperMd).toContain("not scoped");
|
|
expect(apiKey!.warnings).toEqual([
|
|
expect.stringContaining("consume organization credits"),
|
|
]);
|
|
});
|
|
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",
|
|
"honcho:mcp-api-key:workspaceId",
|
|
"make:mcp-api-token:zone",
|
|
"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);
|
|
});
|
|
});
|
|
|
|
|
|
describe("Railway provider", () => {
|
|
it("matches only the hosted endpoint and exposes one vault-backed OAuth method", () => {
|
|
const app = APP_STORE_DEFINITIONS.find((entry) => entry.slug === "railway")!;
|
|
expect(getAppDefinitionForUrl("https://mcp.railway.com")?.slug).toBe("railway");
|
|
for (const url of ["https://mcp.railway.com/path", "https://mcp.railway.com.evil.test", "http://mcp.railway.com"]) expect(getAppDefinitionForUrl(url)?.slug).not.toBe("railway");
|
|
expect(app.methods).toHaveLength(1);
|
|
expect(app.methods[0]).toMatchObject({ key: "mcp-oauth", auth: "oauth", transport: "mcp_remote", ownershipModes: ["dcr", "customer"], riskTier: "S4", defaults: { serverUrl: "https://mcp.railway.com", scopesHint: ["openid", "offline_access", "workspace:member"], oauthAuthorizationParams: { prompt: "consent" } } });
|
|
expect(JSON.stringify(app.methods)).toContain("Live Railway qualification is pending");
|
|
});
|
|
});
|
|
|
|
|
|
describe("tool method permission review", () => {
|
|
const audit = JSON.parse(fs.readFileSync(new URL("../../../doc/connections/tool-method-permission-reviews.json", import.meta.url), "utf8")) as {
|
|
methods: { app: string; method: string; auth: string; policy: string; requestedScopes: string[]; providerDefaultReason?: string; supportedActions: string; evidence: string[]; reviewedAt: string }[];
|
|
};
|
|
const methods = APP_DEFINITIONS.flatMap((app) => app.methods
|
|
.filter((method) => method.purpose !== "channel" && method.purpose !== "ai")
|
|
.map((method) => ({ app, method })));
|
|
it("requires an explicit review for every tool method, including documented scope omissions", () => {
|
|
expect(new Set(audit.methods.map((review) => `${review.app}/${review.method}`)).size).toBe(audit.methods.length);
|
|
expect(audit.methods).toHaveLength(methods.length);
|
|
for (const { app, method } of methods) {
|
|
const review = audit.methods.find((entry) => entry.app === app.slug && entry.method === method.key);
|
|
expect(review, `${app.slug}/${method.key}`).toBeDefined();
|
|
expect(review!.auth).toBe(method.auth);
|
|
expect(review!.supportedActions.length).toBeGreaterThan(15);
|
|
expect(review!.evidence.length).toBeGreaterThan(0);
|
|
if (method.auth === "oauth") {
|
|
expect(method.defaults?.scopesHint ?? []).toEqual(review!.requestedScopes);
|
|
if (!review!.requestedScopes.length) {
|
|
expect(review!.policy).toBe("provider-default");
|
|
expect(review!.providerDefaultReason!.length).toBeGreaterThan(30);
|
|
} else expect(review!.policy).toBe("explicit");
|
|
}
|
|
}
|
|
});
|
|
it("requests Airtable record, schema and comment writes, and Hugging Face repository/job actions", () => {
|
|
expect(APP_DEFINITIONS.find((app) => app.slug === "airtable")!.methods[0]!.defaults!.scopesHint).toEqual([
|
|
"data.records:read", "data.records:write", "schema.bases:read", "schema.bases:write",
|
|
"data.recordComments:read", "data.recordComments:write", "workspacesAndBases:read",
|
|
]);
|
|
expect(APP_DEFINITIONS.find((app) => app.slug === "hugging-face")!.methods[0]!.defaults!.scopesHint)
|
|
.toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]);
|
|
});
|
|
});
|
|
|
|
it("validates native pool catalog entries without inventing an authentication method", () => {
|
|
const entry = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" });
|
|
expect(appDefinitionSchema.parse(entry)).toEqual(entry);
|
|
expect(entry.methods).toEqual([]);
|
|
expect(appDefinitionSchema.safeParse({ ...entry, aiConnectionRouter: undefined }).success).toBe(false);
|
|
expect(appDefinitionSchema.safeParse({ ...entry, methods: APP_STORE_DEFINITIONS[0]!.methods }).success).toBe(false);
|
|
expect(appDefinitionSchema.safeParse({ ...entry, categories: ["developer"] }).success).toBe(false);
|
|
});
|
|
|
|
it("limits explicit DCR registration preference to remote MCP OAuth methods with DCR ownership", () => {
|
|
const entry = structuredClone(APP_DEFINITIONS.find((app) => app.slug === "airtable")!);
|
|
entry.methods[0]!.oauthClientRegistration = "dcr";
|
|
expect(appDefinitionSchema.parse(entry).methods[0]!.oauthClientRegistration).toBe("dcr");
|
|
|
|
const customerOwned = structuredClone(entry);
|
|
customerOwned.methods[0]!.ownershipModes = ["customer"];
|
|
expect(appDefinitionSchema.safeParse(customerOwned).success).toBe(false);
|
|
|
|
const apiKey = structuredClone(entry);
|
|
apiKey.methods[0]!.auth = "api_key";
|
|
expect(appDefinitionSchema.safeParse(apiKey).success).toBe(false);
|
|
|
|
const nonMcp = structuredClone(entry);
|
|
nonMcp.methods[0]!.transport = "rest_api";
|
|
expect(appDefinitionSchema.safeParse(nonMcp).success).toBe(false);
|
|
});
|