mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps give agents governed access to external tools. > - Several provider MCP servers lacked a supported catalog entry or failed during setup. > - Real browser tests identified specific registration, session, and form defects. > - This pull request adds seven catalog entries and fixes the shared paths used by eleven verified providers. > - Users can connect these providers through the existing Apps flow and control each action. ## Linked Issues or Issue Description **What existing behavior does this improve?** The Apps catalog and remote MCP setup, discovery, and action tester. **Subsystem affected** Shared app definitions, the server tool services, and the Apps UI. **Current behavior** Seven providers lack a catalog entry. Airtable can select an advertised client-metadata flow that fails. Calendly rejects the registration name. Tavily needs an initialized session before a call. Firecrawl exposes invalid defaults for optional nested form objects. HTTPS setup can display a callback that differs from the server callback. **Proposed behavior** Add Calendly, Exa, Firecrawl, GSC Wizard, Parallel Search, Tavily, and Windsor.ai. Preserve Airtable, Linear, Make, and PostHog. Use reviewed provider options through the shared MCP and OAuth paths. Display the callback supplied by the server. Omit untouched optional object inputs. **Reason and benefit** Eleven providers passed bounded reads through the real Paperclip browser action tester. This PR includes that verified set and its required shared fixes. Unfinished providers remain outside this change. AgentMail keeps its existing integration. **Breaking changes** No database migration. Existing OAuth credentials and action policies keep their ownership and access rules. Airtable's reviewed method re-registers a retained client-metadata binding through DCR. Tavily's reviewed method initializes sessions before dispatch. The existing customer, managed, and Vercel OAuth gateway paths now refresh on upstream 401 and return `oauth_refreshed_retry_required` (409), requiring an explicit caller retry. They do not replay the rejected call automatically; the next invocation initializes a fresh credential-scoped session when required. Related catalog work: #15545. This branch preserves current master catalog entries and does not add another Google Workspace integration. Open and closed provider PRs and public MCP issues were searched. No duplicate for this verified set was found. The change extends the shipped Connected Apps roadmap item. ## What Changed - Add seven catalog entries with official provider branding and reviewed permissions. - Update Airtable, Linear, and Make metadata and show Make in Apps. - Add authoritative provider source overrides that use the existing generation and review checks. - Add the reviewed Airtable DCR option and compatible Calendly registration names. - Initialize Tavily sessions before discovery and calls, including retained connections. Keep credential-scoped session caching and single call dispatch. - Use the server's callback URL in the OAuth setup UI. - Omit untouched optional object defaults; keep supplied empty strings, false, zero, and object values intact in the action tester, with strict required-child validation. - Require an explicit retry after OAuth refresh instead of replaying a tools/call with missing session headers. - Record all eleven successful reads, write policies, auth-method limits, and qualification caveats. Omit credentials and private account data. - Find chat connector cards through catalog search in browser tests, so pagination does not hide Slack or other later entries. ## Verification - Real browser qualification: eleven bounded reads passed. Catalog refresh and reload proof are recorded in `doc/connections/verified-mcp-qualification-2026-10-08.md`. - Provider writes and actual agent-adapter sessions were not run. Alternative documented auth methods and expiry refresh remain unqualified. - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - Focused shared/catalog tests: 41 passed. Source override tests: 3 passed. Catalog regeneration fixture suite: 9 passed. Latest form regression suites: 31 passed. Gateway suite: 38 passed. Other focused UI suites passed before these last fixes. - UI token gates and both token sync checks: passed. - The catalog regeneration fixture includes the authoritative provider overrides; its full suite passes. The focused OAuth socket case passed on isolated rerun. - Complete local UI suite: 7,866 passed. CLI suite: 511 passed, 6 skipped. Complete shared suite: 892 passed. - The unchanged AgentMail/ClickUp discovery fallback suite passes all 45 cases. An OpenAI login test passed on isolated rerun. - Local broad database coverage is limited by macOS PostgreSQL shared-memory exhaustion (`shmget: No space left on device`, not disk space). The broad run was interrupted after diagnosis; no host settings or other running services were changed. - CI found that the Slack browser test assumed its catalog card was on the first page. The test now uses catalog search; the Slack case passes locally. Adjacent GitHub and iMessage cases could not start locally because embedded PostgreSQL initialization failed before browser assertions. - Final commit [`febe4520e`](https://github.com/paperclipai/paperclip/commit/febe4520e13d4b3a4121eafc3d22540c2b11379d): all 54 checks passed, with none pending or failed. [CI run](https://github.com/paperclipai/paperclip/actions/runs/37847182065) passed typecheck, build, all general and serialized test suites, all eight browser shards, runner checks, canary dry run, and the aggregate verification gate. - Greptile reviewed that exact final commit at 2026-10-08 21:33 UTC and returned 5/5 with no outstanding findings or unresolved threads. - PR UI preview against the existing local test server: Calendly catalog/setup observed; Firecrawl read passed after expanding More options with nested optional inputs untouched. This retest proves frontend behavior, not the revised OAuth backend against a live provider. <details> <summary>Browser evidence</summary>    </details> ## Risks - Provider registration and consent behavior can change. Airtable's DCR option is explicit and keeps the existing issuer, resource, redirect, and PKCE checks. - Tavily uses initialized sessions without automatic call retries. After a successful OAuth refresh following 401, callers now receive a retry-required result. A later explicit retry can still require reauthorization if the provider rejects the refreshed token. Provider writes are not live-qualified. - Optional object cleanup affects the shared action tester. Regression tests cover absent objects, required children, defaults, and populated values. - GSC Wizard's underlying Google data scopes and Google flow completion were not independently verified. Its account reported paid/trial metadata of unknown origin. No purchase was performed. - No database migration, new AgentMail integration, or change to existing action grants is included. ## Model Used OpenAI GPT-6 through Codex assisted with implementation, research, tool use, and code execution. The root backend model ID and context window are not exposed in this session. The cheaper subagents used OpenAI `gpt-6-luna`. No model context size is inferred. ## 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 - [ ] I have run tests locally and they pass — targeted checks and complete UI/CLI/shared suites passed; the broad local database run was blocked by the macOS PostgreSQL startup limitation documented above. The full database/workspace suite passed in CI. - [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>
1411 lines
53 KiB
TypeScript
1411 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,
|
|
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: "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";
|
|
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(60);
|
|
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("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 63 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(63);
|
|
expect(
|
|
new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)),
|
|
).toHaveProperty("size", 63);
|
|
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("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("requests Hugging Face MCP read, contribution and job scopes", () =>
|
|
expect(
|
|
APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0]
|
|
?.defaults?.scopesHint,
|
|
).toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]));
|
|
it("defaults every new connection action to allowed except Enterpret writes", () => {
|
|
for (const app of APP_DEFINITIONS)
|
|
for (const method of app.methods) {
|
|
if (app.slug === "enterpret") {
|
|
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(
|
|
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",
|
|
"bitly",
|
|
"brex",
|
|
"candid",
|
|
"coda",
|
|
"context7",
|
|
"egnyte",
|
|
"embat",
|
|
"kernel",
|
|
"local-falcon",
|
|
"manufact",
|
|
"oreilly",
|
|
"planetscale",
|
|
"razorpay",
|
|
"sanity",
|
|
"similarweb",
|
|
"ticket-tailor",
|
|
"ticktick",
|
|
"xero",
|
|
]);
|
|
expect(APP_STORE_DEFINITIONS).toHaveLength(77);
|
|
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 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("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);
|
|
});
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
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);
|
|
});
|