Files
PaperClipAI/packages/shared/src/app-definitions.ts
T
88ff98b83d feat(apps): add read-only Enterpret MCP with OAuth and org tokens (#13906)
## Thinking Path

> - Paperclip agents need governed access to Enterpret's hosted MCP
tools for customer feedback.
> - The official Enterpret MCP is read-only. Enterpret Agent’s beta
write MCP is a separate service and is outside this PR.
> - This PR supports both organization auth tokens and personal browser
OAuth for the official MCP.
> - Enterpret committed to fixing OAuth scope behavior and reducing its
token-revocation cache window. The provider fixes will follow after
connector deployment and do not gate this release. Paperclip functional
validation remains required. New tools stay quarantined on refresh.
> - This PR combines the original connector and Vivek's token-path work
on current master, with live self-hosted QA recorded in the validation
guide.

## Linked Issues or Issue Description

No public issue covers this connector. This PR incorporates the work
from #14737. The independent OAuth scope-provenance fix is #14059; it is
not required for the organization-token path. #13890 is a merged
connector precedent. #13692 provides the connector-authoring skill.

**Problem or motivation**

The generic MCP setup can reach Enterpret, but it has no reviewed
Enterpret entry, method-specific setup, artwork, or policy defaults. The
original connector branch had also fallen behind master.

**Proposed solution**

Publish the Enterpret catalog entry with an organization auth token as
the selectable method. Support browser OAuth through instance-local DCR
on the same read-only endpoint. Default `run_graph_query` to Ask first
and quarantine tools newly found on refresh.

## What Changed

- Added the Enterpret app definition, generated registry, official icon,
Browse card, setup copy, tests, and Storybook states.
- Combined #14737 and resolved the current-master conflicts, including
the connector permission audit.
- Preserved catalog quarantine on token reconnect and updated expiry
guidance to follow the token's dashboard expiry. The QA token displayed
three months, contradicting the former six-month copy.
- Enabled personal OAuth for the official read-only MCP. Removed the
write-capability inference and OAuth draft-only setup restriction. The
beta Agent endpoint is excluded.
- Preserved tool quarantine during token reconnect, with a regression
test for existing quarantined and newly discovered tools.
- Added OAuth scope and revocation-cache disclosures and an interactive
Storybook retry check.
- Merged master `d9f600043` into the existing PR branch without
rewriting history. Resolved four conflicts, regenerated the combined
registry, and retained the model-provider assertions with a catalog
count of 66.
- Recorded live QA and its limits in
[doc/connections/ENTERPRET.md](https://github.com/paperclipai/paperclip/blob/950bacc52/doc/connections/ENTERPRET.md).

## Verification

Current head: `6c73d3084202ccbb25080e7a4e9c3467bc1341dd`. All 513
focused connector, discovery, OAuth, policy, shared-definition, and
UI-handoff tests passed across 11 suites. Full repository typecheck,
build, Storybook build, token gates, module boundaries, Node policy,
no-git-push policy, and typecheck-build-gaps passed. Release-registry
tests passed 129/129 with modern Bash. The four failures with macOS Bash
3.2 also reproduced on untouched master.

The full test suite passed in CI on this exact head, including every
general-test and serialized-server shard and all eight E2E shards. The
duplicate local `pnpm test:run` was stopped after these remote test
lanes passed; it did not complete locally. The combined registry was
regenerated. Regeneration also reproduces existing Linear and AgentMail
definition drift on untouched master; those unrelated changes were
excluded.

On an isolated self-hosted Paperclip instance, an organization token
connected and discovered eight actions. `get_organization_details`
returned the intended organization before and after reconnect. Catalog
refresh, invalid-token failure and valid-token recovery, activity
logging, local disable, and local removal were exercised. No feedback
quotes or graph query were requested. Paperclip revoked both QA
connection secrets and archived the QA connections.

The token-path agent access preview correctly showed an action Off, but
the board Test endpoint still invoked it as the board user. This shared
Test-path mismatch is documented; it is not evidence that a real agent
can bypass policy. An actual token-path agent-session denial, a real
agent process, expiry recovery, server/VPS, and Cloud remain unverified.

Enterpret's dashboard confirmed the QA token was revoked, but the same
token immediately reconnected and completed a safe read. Vivek explained
this as a 24-hour validity cache and committed to reducing the window.
The new maximum delay and deployment remain unverified. The earlier
OAuth check reported `mcp:write` and `email` for an `mcp:read` request.
This is a scope mismatch; it did not prove access to write tools. On
2026-10-01 the account holder relayed Vivek’s clarification that the
official MCP is read-only and the beta Agent write MCP is separate.

## Risks

The organization token can read the organization's customer feedback,
and provider-side revocation did not immediately stop access. Operators
should check each token's displayed expiry and remove Paperclip's local
connection when retiring access. The account holder confirmed the
provider fixes are fast follows after deployment. Release can proceed
with the current scope reporting and up-to-24-hour revocation delay
documented, once fresh OAuth authorization/refresh, real-agent
allow/deny, CI, and review pass. Retest the reduced revocation window
after Enterpret deploys it. Scope labels alone must not be used to claim
write access.

Greptile is 5/5 on head `6c73d3084` with no actionable findings. All
current-head CI checks pass, including the canary dry run. Live
agent-session denial and fresh OAuth refresh remain explicit deployment
QA items. The board Test panel does not prove agent authorization.
Provider scope-reporting and revocation-cache fixes are accepted fast
follows after deployment.

## Model Used

Original connector and validation record: Claude Opus 5 through Claude
Code. Integration, current-master reconciliation, and token-path QA:
OpenAI Codex, GPT-6 family (exact host variant not exposed), using
repository tools and an isolated runtime. Contributor commits retain
their authors.

## 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 linked existing issues or described the issue in-PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal issue
ID
- [x] I have run relevant local tests and checks
- [x] I have updated relevant documentation
- [x] I have considered and documented the risks above
- [x] All current-head CI gates are green
- [x] Greptile is 5/5 with no actionable follow-ups

If squash-merging, preserve contributor credit in the squash message:

Co-Authored-By: Vivek Kaushal <kaushalvivek@users.noreply.github.com>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Vivek Kaushal <vivek@enterpret.com>
2026-10-06 14:35:51 -05:00

279 lines
9.6 KiB
TypeScript

import { APP_DEFINITIONS } from "./app-definitions.generated.js";
import { SELF_SERVE_MCP_CANDIDATES } from "./self-serve-mcp-research.js";
import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-definition.js";
import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
"anthropic", "openai", "openrouter", "xai", "google", "bedrock",
"responses-api", "messages-api", "chat-completions-api", "local",
"agentmail",
"browser-use-cloud",
"cognee",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"enterpret",
"zapier",
"arcade",
"executor",
"slack",
"notion",
"railway",
"posthog",
"linear",
"google-sheets",
"context7",
"shopify",
"composio",
"gmail",
"google-drive",
"google-docs",
"google-slides",
"google-calendar",
"google-chat",
"google-people",
"google-workspace-search",
"github",
"github-code-review-bot",
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
]);
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
CONNECTABLE_APP_SLUGS.has(app.slug)
);
/**
* Definitions retained for existing connections and later verification, but
* intentionally withheld from the customer-facing store. Keeping visibility
* separate from recognition avoids breaking saved connections when a provider
* is pulled from Browse or reserved for a future first-party experience.
*/
export const APP_STORE_HIDDEN_SLUGS = new Set([
"beehiiv",
"bitly",
"brex",
"candid",
"coda",
"context7",
"egnyte",
"embat",
"kernel",
"local-falcon",
"make",
"manufact",
"oreilly",
"planetscale",
"razorpay",
"sanity",
"similarweb",
"ticket-tailor",
"ticktick",
"xero",
]);
export const APP_STORE_DEFINITIONS = CONNECTABLE_APP_DEFINITIONS.filter((app) =>
!APP_STORE_HIDDEN_SLUGS.has(app.slug)
);
export const DEFAULT_OWNERSHIP_AVAILABILITY: Record<ToolConnectionOwnership, boolean> = {
platform_shared: false,
platform_provisioned: false,
customer: true,
dcr: true,
};
export function getConnectableAppDefinition(slug: string): AppDefinition | null {
return CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug) ?? null;
}
export function getAppStoreDefinition(slug: string): AppDefinition | null {
return APP_STORE_DEFINITIONS.find((app) => app.slug === slug) ?? null;
}
export function isAppStoreVisibleSlug(slug: string | null | undefined): boolean {
return Boolean(slug && !APP_STORE_HIDDEN_SLUGS.has(slug) && CONNECTABLE_APP_SLUGS.has(slug));
}
function wildcardPatternToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}
export function getAppDefinitionForUrl(
link: string,
definitions: readonly AppDefinition[] = CONNECTABLE_APP_DEFINITIONS,
): AppDefinition | null {
let normalized: string;
try {
normalized = new URL(link.trim()).toString();
} catch {
return null;
}
return definitions.find((app) =>
app.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
) ?? null;
}
export function getAvailableConnectionMethods(app: AppDefinition): ConnectionMethodDef[] {
const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY;
return app.methods.filter((method) =>
method.ownershipModes.some((ownership) => availability[ownership] !== false)
);
}
/**
* Pick the method that gives a new connection the app's useful write surface.
*
* Google Workspace publishes separate read and write capability groups. The
* read method is intentionally listed first for documentation, but treating
* array order as a product default silently created read-only connections and
* left every write action Off. Capability metadata is the durable signal; apps
* without an explicit write/draft capability retain their declared order.
*/
export function getRecommendedConnectionMethod(
methods: readonly ConnectionMethodDef[],
): ConnectionMethodDef | null {
const recommendedCapability = (candidates: readonly ConnectionMethodDef[]) => candidates.find((method) => {
const capabilityKey = method.capabilityProfile?.key;
return capabilityKey === "write" || capabilityKey === "draft";
});
const managedMethods = methods.filter((method) =>
method.oauthStrategy === "paperclip_cloud_connector"
|| method.oauthStrategy === "paperclip_id_connector"
);
// Prefer the permissions needed for agent work, then the simplest sign-in.
// Explicit read-only selections pass their own capability group here.
return recommendedCapability(managedMethods)
?? recommendedCapability(methods)
?? managedMethods[0]
?? methods[0]
?? null;
}
export function getAvailableConnectionMethod(
app: AppDefinition,
methodKey?: string | null,
): ConnectionMethodDef | null {
const methods = getAvailableConnectionMethods(app);
return methodKey
? methods.find((method) => method.key === methodKey) ?? null
: getRecommendedConnectionMethod(methods);
}
export function connectionMethodSupportsAutomaticOAuth(method: ConnectionMethodDef | null | undefined): boolean {
return method?.auth === "oauth" && (
(method.oauthStrategy === "paperclip_cloud_connector" || method.oauthStrategy === "paperclip_id_connector")
|| method.ownershipModes.includes("dcr")
);
}
export function connectionMethodAcceptsCustomerOAuthClient(method: ConnectionMethodDef | null | undefined): boolean {
return method?.auth === "oauth"
&& !method.oauthStrategy
&& method.ownershipModes.includes("customer");
}
export function connectionMethodSupportsCatalogSetup(method: ConnectionMethodDef | null | undefined): boolean {
if (!method) return false;
if (method.transport === "runtime_auth") return Boolean(method.ai);
if (method.auth === "none" || method.auth === "api_key") return true;
return connectionMethodSupportsAutomaticOAuth(method)
|| connectionMethodAcceptsCustomerOAuthClient(method);
}
export function connectionMethodRequiresConfiguration(method: ConnectionMethodDef | null | undefined): boolean {
if (!method) return false;
const visibleTenantFields = method.tenantFields?.filter((field) => !field.hidden) ?? [];
const visibleExtensionFields = method.extensionFields?.filter((field) => !field.hidden) ?? [];
return Boolean(
method.credentialFields?.length
|| visibleTenantFields.length
|| visibleExtensionFields.length
|| method.configRequirements?.atLeastOneOf?.length
// "Use your own OAuth app" is an advanced alternative when DCR/CIMD is
// available, not a required setup field. Only customer-client-only methods
// must stop on the configuration screen.
|| (
connectionMethodAcceptsCustomerOAuthClient(method)
&& !connectionMethodSupportsAutomaticOAuth(method)
),
);
}
export function appSupportsCatalogSetup(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodSupportsCatalogSetup));
}
export function isConnectableAppSlug(slug: string | null | undefined): boolean {
return Boolean(slug && CONNECTABLE_APP_SLUGS.has(slug));
}
export function appSupportsAutomaticOAuth(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodSupportsAutomaticOAuth));
}
export function appAcceptsCustomerOAuthClient(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodAcceptsCustomerOAuthClient));
}
export function credentialConfigPath(field: FieldDef, method?: ConnectionMethodDef | null): string {
if (method?.transport === "local_stdio" && method.keyPlacement?.location === "env") return `env.${field.key}`;
return `credentials.${field.key}`;
}
/** Older credential references used bare names; current references use paths. */
export function connectionCredentialConfigPath(ref: { name: string }): string {
return /^(credentials|headers|oauth|remote)\./.test(ref.name)
? ref.name
: `credentials.${ref.name}`;
}
export function resolveConnectionMethodServerUrl(
method: ConnectionMethodDef,
configValues: Record<string, string | boolean>,
): string | null {
const template = method.defaults?.serverUrlTemplate;
if (!template) return method.defaults?.serverUrl ?? null;
let missingValue = false;
const resolved = template.replace(/\{([a-zA-Z0-9_-]+)\}/g, (_placeholder, key: string) => {
const value = configValues[key];
if (value === undefined || String(value).trim().length === 0) {
missingValue = true;
return "";
}
return encodeURIComponent(String(value).trim());
});
if (missingValue) return null;
try {
return new URL(resolved).toString();
} catch {
return null;
}
}
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
// Keep the parameters in the public contract: callers resolve defaults for a
// concrete app/method. Connection finalization remains a configure-authorized,
// audited operation, and Ask first stays available as an operator-selected
// policy for any action after the connection is made.
void methodKey;
// Enterpret's run_graph_query self-reports readOnlyHint but executes Cypher
// and has not been established as read-only. Classify it as write (server
// classifyRisk) and require Ask first so new token connections do not ship
// that action as Allowed.
if (app.slug === "enterpret") {
return {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
};
}
return {
access: "all_agents",
askFirstRiskLevels: [],
};
}