diff --git a/doc/connections/CONNECTOR-PERMISSION-AUDIT.md b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md index 5f0844ae68b..e5ccec2a711 100644 --- a/doc/connections/CONNECTOR-PERMISSION-AUDIT.md +++ b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md @@ -10,7 +10,8 @@ Methods reviewed after this audit carry their own `reviewedAt` date in the same ledger; the counts above are not restated. Later additions: [Neon](./NEON.md) (`mcp-oauth`, `mcp-api-key`; 2026-10-02), [Superagent](./SUPERAGENT.md) (`mcp-api-key`; 2026-10-06), [Gauge](./GAUGE.md) -(`mcp-oauth`, `mcp-api-key`; 2026-10-08). +(`mcp-oauth`, `mcp-api-key`; 2026-10-08), [Ditto](./DITTO.md) +(`mcp-oauth`, `mcp-api-key`; 2026-10-10). ## Shared credential failure diff --git a/doc/connections/DITTO.md b/doc/connections/DITTO.md new file mode 100644 index 00000000000..ac4be3c6f55 --- /dev/null +++ b/doc/connections/DITTO.md @@ -0,0 +1,243 @@ +# Ditto + +Updated: 2026-10-10. Status: catalog definition reviewed against official +documentation and live provider metadata; live account qualification +outstanding. + +Ditto (`dittowords.com`) is a single source of truth for product copy: text +items, a reusable component library, variants and translations, and style +guides that agents must follow when they write UI text. It appears in Apps and +uses Paperclip's shared remote-MCP connection, vault, catalog, grants, +policies, gateway, and audit trail. It is a resource connection, not Paperclip +sign-in. No plugin or database migration is required. The only +provider-specific runtime code is a reviewed risk-classification rule. + +Paperclip connects to Ditto's hosted MCP server at +`https://api.dittowords.com/v2/mcp` (Streamable HTTP) with two methods: + +- `mcp-oauth`: browser sign-in through dynamic client registration on Ditto's + Auth0 tenant. The connection acts as the Ditto user who signs in. +- `mcp-api-key`: a workspace API key stored as a Paperclip secret and sent as + an `Authorization: token ` header. Ditto uses the `token` scheme, not + `Bearer`. + +Ditto also runs a separate documentation MCP server at +`https://developer.dittowords.com/mcp`. This connection does not use it. + +This curated connection is the polished route: it provides branding, method +guidance, a delete and link warning, and the risk rule. The same URL can also +be connected from **Connect your own MCP server**. See +[Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md). + +## Service involvement + +Ditto hosts the MCP resource, and Auth0 hosts its authorization server. +Paperclip stores the OAuth tokens or the key as secret references, discovers +tools with `tools/list`, and sends the credential on every governed call. No +Paperclip-operated vendor relay is involved; cloud and self-hosted instances +use the same path. + +## Endpoints + +| Purpose | URL | +| --- | --- | +| MCP resource | `https://api.dittowords.com/v2/mcp` | +| Protected-resource metadata | `https://api.dittowords.com/.well-known/oauth-protected-resource` | +| Authorization-server metadata | `https://login.dittowords.com/.well-known/oauth-authorization-server` | +| Authorize | `https://login.dittowords.com/authorize` | +| Token | `https://login.dittowords.com/oauth/token` | +| Dynamic client registration | `https://login.dittowords.com/oidc/register` | +| Paperclip connect (wizard) | `POST /api/companies/:companyId/tools/apps/connect` | +| Paperclip OAuth start | `POST /api/tools/oauth/:connectionId/start` | +| Paperclip callback | `GET /api/tools/oauth/callback` | + +Redirect constraints: `https-or-loopback-http`. Dynamic client registration +needs no administrator setup: each Paperclip instance registers its own +client with its own callback URL. + +```mermaid +sequenceDiagram + autonumber + actor U as User's browser + participant UI as Paperclip UI
/apps/connect?source=ditto + participant S as Paperclip instance server
(cloud or self-hosted, same path) + participant M as api.dittowords.com
(MCP resource) + participant A as login.dittowords.com
(Auth0 authorization server) + + U->>UI: Click "Continue to sign in" + UI->>S: POST /companies/:id/tools/apps/connect with appKey ditto + S->>M: GET /.well-known/oauth-protected-resource (RFC 9728) + M-->>S: authorization_servers is login.dittowords.com + S->>A: GET /.well-known/oauth-authorization-server (RFC 8414) + A-->>S: authorize, token and registration endpoints + alt First connect on this instance + S->>A: POST /oidc/register (RFC 7591 DCR, public client, PKCE) + A-->>S: client_id (stored and reused) + else Client already known + S->>S: Reuse stored client_id + end + S-->>UI: authorize URL with PKCE S256, state, scopes and resource + UI->>U: Redirect browser to the authorize URL + U->>A: GET /authorize (openid profile email offline_access, resource is /v2/mcp) + A-->>U: Ditto sign-in and consent + U->>S: 302 to GET /api/tools/oauth/callback with code and state + S->>A: POST /oauth/token (code and code_verifier) + A-->>S: access_token and refresh_token + S->>M: POST /v2/mcp initialize and tools/list (Bearer access_token) + M-->>S: tool catalog + S-->>UI: Connection ready, open Permissions +``` + +The API-key method skips the authorization server: Paperclip sends +`Authorization: token ` directly to `/v2/mcp` for discovery and every +governed call. + +The authorization server supports `authorization_code` and `refresh_token`, +PKCE `S256`, and public clients (`token_endpoint_auth_method: none`). It +advertises only identity scopes. Paperclip sends the MCP resource URL as the +`resource` parameter so Auth0 issues a token for the Ditto API. + +## Scopes + +Paperclip requests `openid`, `profile`, `email` and `offline_access`. Ditto +publishes no API scopes: the signed-in user's Ditto permissions control +access. `offline_access` is required because Auth0 issues a refresh token only +when a client asks for it; without it the connection expires with the access +token. The authorization server does not reject unknown scopes, so this list +is recorded as an explicit review in +[`tool-method-permission-reviews.json`](./tool-method-permission-reviews.json). + +## Administrator setup + +Browser sign-in: + +1. In Paperclip, open **Apps → Browse → Ditto** and click + **Continue to sign in**. +2. Sign in to Ditto and approve access. + +API key: + +1. In Ditto, open **Developers** (`https://app.dittowords.com/developers`). + Enable developer integrations if the page asks. Under **API keys**, click + **+ Create API key** and copy the key. It is shown only once. Use a separate + key for Paperclip so it can be removed independently. +2. In Paperclip, open **Apps → Browse → Ditto**, open **Advanced**, choose + **Use an API key**, paste the key, and click **Connect**. + +After either method, set delete and link actions to **Ask first** or **Off** +on the connection's **Permissions** screen if agents run unattended. + +To disconnect, remove the connection in Paperclip. For an API key, also delete +the key on Ditto's **Developers** page. + +## Capabilities and policy + +Both methods are risk tier S4, because the normal tool catalog includes +permanent deletes. + +Ditto documents 41 tools in these groups: + +- Style guides and rules: `get_styleguide_rules`, `suggest_edit` (checks text + against the rules), and tools to list, create and update style guides and to + create, update and delete rules. +- Text items: `search_ditto_text`, and tools to create, update, translate and + delete text items and to link synced Figma text nodes. +- Library components: create, update, translate and publish components, and + link or unlink text items. +- Component folders, projects, project blocks, variables and variants: list, + create, update and delete. +- Workspace: `list_statuses`, `get_workspace_tags`. + +Delete tools cannot be undone. Linking a text item to a library component +replaces its developer ID, so code that used the old ID stops matching. +Unlinking gives each text item a new developer ID. Translate tools replace +existing variant text. + +Paperclip applies a reviewed Ditto risk rule in `classifyRisk`, because the +generic classifier reads `translate_*`, `link_*` and `unlink_*` as reads. +Only `list_*`, `get_*` and `search_*` tools, `suggest_edit`, and tools Ditto +marks `readOnlyHint: true` are reads. `delete_*` tools are destructive. +Everything else is a write. A `readOnlyHint: false` or `writeHint: true` +annotation always makes a tool a write. + +## Transport and auth + +- Transport: `mcp_remote`, Streamable HTTP. +- OAuth: DCR on Auth0, public client, PKCE, `resource` parameter, scopes + above. Acts as the signed-in user. +- API key: `Authorization: token `. Keys reach all data in the + workspace, are not scoped, and act as the user who created them. Keys + require developer integrations on the Ditto plan. Paperclip cannot narrow an + issued key. + +## Resource filters + +None. Tools such as `get_styleguide_rules` take `projectIds` per call, but +Ditto documents no query or header option that narrows the hosted server. +Narrow with per-action policies. + +## Manifest + +| Field | Value | +| --- | --- | +| Slug | `ditto` | +| Category | `content` | +| Methods | `mcp-oauth` (`dcr`, S4); `mcp-api-key` (`customer`, S4) | +| Server URL | `https://api.dittowords.com/v2/mcp` | +| Credential | `authorization`, password, required | +| Key placement | header `Authorization`, prefix `token ` | +| Console links | keys `https://app.dittowords.com/developers`, docs `https://developer.dittowords.com/guides/connect-the-mcp` | +| Capability profile | `write` (shared by both methods, so no picker; its description, with the delete and link warning, shows before sign-in) | + +The definition is generated from the `ditto` row in +`packages/shared/src/self-serve-mcp-research.json` and the `ditto` branch of +`specialMethodsFor` in `scripts/ingest-app-definitions.mjs`. + +## Wizard path + +`/apps/connect?source=ditto` defaults to **Sign in with Ditto** and shows the +capability description before sign-in. Under **Advanced**, **Use an API key** +shows the key field; **Connect** stays disabled until a key is entered. A +successful sign-in or key check and tool discovery lead to the connection's +Permissions screen. + +## Governance defaults + +- Default profile and bindings: the standard connection profile; every + discovered action starts Allowed under the current product default. +- Policies: operators narrow delete, link and unlink tools to Ask first or Off + on the Permissions screen. +- Risk: the reviewed Ditto rule in `classifyRisk` described above. +- Quarantine rules: the shared defaults; no Ditto-specific exceptions. + +## Brand provenance + +Ditto's site ships a 64px favicon and an abstract web-clip SVG that does not +read as the brand at tile size. The "D." mark on an orange tile is the avatar +of Ditto's official GitHub organization (`dittowords`, whose profile links +`https://dittowords.com`), published as a 460×460 opaque PNG. That file is +used unchanged in both themes. + +| File | Source | SHA-256 | +| --- | --- | --- | +| `ui/public/brands/apps/ditto.png` (460×460) | `https://avatars.githubusercontent.com/u/68580354?v=4&s=512` | `dccbb9d5bd4130d48d8a7b16bdddf46745aa0efa58c3855c7994629db922e50c` | + +## Validation hook + +- Environment: definition review on 2026-10-10 against `origin/master`; no + Ditto account was used. +- Metadata probe: an unauthenticated `initialize` on `/v2/mcp` returned HTTP + 401 with `resource_metadata`; the protected-resource and authorization-server + documents returned the endpoints above; dynamic client registration + succeeded for a loopback redirect; the authorize endpoint redirected to + Ditto's sign-in page with the reviewed scopes and the `resource` parameter. +- Deterministic tests: manifest shape, store visibility, artwork, URL + recognition, reviewed scopes, `token` header projection with the key kept + out of connection config, risk classification of every documented + read-looking write, and the connect form's sign-in default, pre-sign-in + warning and API-key gating. +- Connect evidence, catalog evidence, allowed read, governed write, denied + case, revoke, audit: not run. Both methods still need the full live + lifecycle with a Ditto workspace before this connection is considered + qualified. Confirm on that pass that Auth0 issues a refresh token and that + the token is accepted by `/v2/mcp`. diff --git a/doc/connections/README.md b/doc/connections/README.md index df86a57e61a..4c8f13512d2 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md). Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), -[Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), +[Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), [Ditto](./DITTO.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and [Enterpret](./ENTERPRET.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index b81e206d17f..e04dd4d36fa 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -452,6 +452,45 @@ "liveProof": "Anonymous Paperclip connection succeeded. Discovery returned 2 read actions (resolve-library-id and query-docs), 0 write actions. Resolve React returned official React docs ID /reactjs/react.dev (source reputation High) in 2.9s; query-docs returned useEffect cleanup guidance from React documentation in 3.0s. After page reload, agent restriction remained Just agents I pick with 0 selected and the same bounded query succeeded again in 3.1s. No API key, OAuth grant, account data, private repository, provider writes, or agent run.", "evidenceLimit": "Live proof covers anonymous MCP initialize/discovery and one narrow public React documentation lookup through Paperclip's actual Apps connection and Test dialog, including a successful repeat after reload. It does not prove elevated API-key rate limits, private-repository access, or any non-public provider content." }, + { + "app": "ditto", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "profile", + "email", + "offline_access" + ], + "capability": "write", + "supportedActions": "Read style guide rules and check text against them; search, create, update, translate and permanently delete text items; create, update, translate and publish library components and link or unlink text items to them (linking replaces a text item's developer ID); link synced Figma text nodes; manage component folders, projects, project blocks, variables, variants, style guides and style guide rules; read statuses and workspace tags. The connection acts as the Ditto user who signs in.", + "evidence": [ + "https://developer.dittowords.com/guides/connect-the-mcp", + "https://developer.dittowords.com/mcp-reference/tools", + "https://api.dittowords.com/.well-known/oauth-protected-resource", + "https://login.dittowords.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-10", + "liveProof": "not-run" + }, + { + "app": "ditto", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Read style guide rules and check text against them; search, create, update, translate and permanently delete text items; create, update, translate and publish library components and link or unlink text items to them (linking replaces a text item's developer ID); link synced Figma text nodes; manage component folders, projects, project blocks, variables, variants, style guides and style guide rules; read statuses and workspace tags.", + "evidence": [ + "https://developer.dittowords.com/guides/connect-the-mcp", + "https://developer.dittowords.com/mcp-reference/tools", + "https://developer.dittowords.com/api-reference/authentication" + ], + "reviewedAt": "2026-10-10", + "liveProof": "not-run", + "keyPermissions": "Ditto API keys are not scoped: a key reaches all data in its workspace, including permanent deletes, and actions are attributed to the user who created the key. Create a separate key for Paperclip so it can be removed independently, and set delete and link actions to Ask first before agents run unattended. Paperclip cannot narrow an existing key." + }, { "app": "egnyte", "method": "mcp-oauth", diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index 4070f908512..7378d29fd84 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -14,6 +14,7 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://workspace.google.com/")?.slug).toBe("google-workspace"); expect(getAppDefinitionForUrl("https://mcp.neon.tech/mcp")?.slug).toBe("neon"); expect(getAppDefinitionForUrl("https://app.withgauge.com/mcp")?.slug).toBe("gauge"); + expect(getAppDefinitionForUrl("https://api.dittowords.com/v2/mcp")?.slug).toBe("ditto"); expect(getAppDefinitionForUrl("https://www.superagent.sh/mcp")?.slug).toBe("superagent"); }); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index cc57bf2b612..677f8e57953 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -81,28 +81,29 @@ import a79 from "./app-definitions/windsor-ai.json" with { type: "json" }; import a80 from "./app-definitions/gsc-wizard.json" with { type: "json" }; import a81 from "./app-definitions/clickup.json" with { type: "json" }; import a82 from "./app-definitions/klaviyo.json" with { type: "json" }; -import a83 from "./app-definitions/google-workspace.json" with { type: "json" }; -import a84 from "./app-definitions/gmail.json" with { type: "json" }; -import a85 from "./app-definitions/google-drive.json" with { type: "json" }; -import a86 from "./app-definitions/google-docs.json" with { type: "json" }; -import a87 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a88 from "./app-definitions/google-slides.json" with { type: "json" }; -import a89 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a90 from "./app-definitions/google-chat.json" with { type: "json" }; -import a91 from "./app-definitions/google-people.json" with { type: "json" }; -import a92 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a93 from "./app-definitions/openai.json" with { type: "json" }; -import a94 from "./app-definitions/openrouter.json" with { type: "json" }; -import a95 from "./app-definitions/xai.json" with { type: "json" }; -import a96 from "./app-definitions/google.json" with { type: "json" }; -import a97 from "./app-definitions/bedrock.json" with { type: "json" }; -import a98 from "./app-definitions/responses-api.json" with { type: "json" }; -import a99 from "./app-definitions/messages-api.json" with { type: "json" }; -import a100 from "./app-definitions/chat-completions-api.json" with { type: "json" }; -import a101 from "./app-definitions/local.json" with { type: "json" }; -import a102 from "./app-definitions/daytona.json" with { type: "json" }; -import a103 from "./app-definitions/monday.json" with { type: "json" }; -import a104 from "./app-definitions/paperclip-notebook.json" with { type: "json" }; -import a105 from "./app-definitions/speko.json" with { type: "json" }; +import a83 from "./app-definitions/ditto.json" with { type: "json" }; +import a84 from "./app-definitions/google-workspace.json" with { type: "json" }; +import a85 from "./app-definitions/gmail.json" with { type: "json" }; +import a86 from "./app-definitions/google-drive.json" with { type: "json" }; +import a87 from "./app-definitions/google-docs.json" with { type: "json" }; +import a88 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a89 from "./app-definitions/google-slides.json" with { type: "json" }; +import a90 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a91 from "./app-definitions/google-chat.json" with { type: "json" }; +import a92 from "./app-definitions/google-people.json" with { type: "json" }; +import a93 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a94 from "./app-definitions/openai.json" with { type: "json" }; +import a95 from "./app-definitions/openrouter.json" with { type: "json" }; +import a96 from "./app-definitions/xai.json" with { type: "json" }; +import a97 from "./app-definitions/google.json" with { type: "json" }; +import a98 from "./app-definitions/bedrock.json" with { type: "json" }; +import a99 from "./app-definitions/responses-api.json" with { type: "json" }; +import a100 from "./app-definitions/messages-api.json" with { type: "json" }; +import a101 from "./app-definitions/chat-completions-api.json" with { type: "json" }; +import a102 from "./app-definitions/local.json" with { type: "json" }; +import a103 from "./app-definitions/daytona.json" with { type: "json" }; +import a104 from "./app-definitions/monday.json" with { type: "json" }; +import a105 from "./app-definitions/paperclip-notebook.json" with { type: "json" }; +import a106 from "./app-definitions/speko.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105,a106] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 7629aadb6e0..b647d66d3b3 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -284,7 +284,7 @@ describe("AppDefinition catalog", () => { "google-workspace-search", ]), ); - expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(63); + expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(64); expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([ "g2", "vercel", @@ -450,15 +450,15 @@ describe("AppDefinition catalog", () => { 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", () => { + it("keeps a complete, unique, dated evidence ledger for all 68 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(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(68); expect( new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)), - ).toHaveProperty("size", 67); + ).toHaveProperty("size", 68); 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:"); @@ -886,7 +886,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(83); + expect(APP_STORE_DEFINITIONS).toHaveLength(84); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -1171,6 +1171,55 @@ describe("AppDefinition catalog", () => { expect(method.warnings?.length).toBe(2); } }); + it("connects Ditto's hosted server with browser sign-in or a workspace API key", () => { + const ditto = APP_DEFINITIONS.find((app) => app.slug === "ditto")!; + expect(ditto).toMatchObject({ + name: "Ditto", + categories: ["content"], + urlPatterns: ["https://api.dittowords.com/*"], + docsUrl: "https://developer.dittowords.com/guides/connect-the-mcp", + branding: { logoUrl: "/brands/apps/ditto.png" }, + redirectConstraints: "https-or-loopback-http", + }); + expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "ditto")).toBe(true); + expect(ditto.methods.map((method) => method.key)).toEqual(["mcp-oauth", "mcp-api-key"]); + const [oauth, apiKey] = ditto.methods; + expect(oauth).toMatchObject({ + transport: "mcp_remote", + auth: "oauth", + ownershipModes: ["dcr"], + riskTier: "S4", + // Ditto signs in through Auth0, which issues a refresh token only for + // offline_access. + defaults: { + serverUrl: "https://api.dittowords.com/v2/mcp", + scopesHint: ["openid", "profile", "email", "offline_access"], + }, + }); + expect(apiKey).toMatchObject({ + transport: "mcp_remote", + auth: "api_key", + ownershipModes: ["customer"], + riskTier: "S4", + defaults: { serverUrl: "https://api.dittowords.com/v2/mcp" }, + // Ditto API keys use the `token` scheme, not `Bearer`. + keyPlacement: { location: "header", name: "Authorization", prefix: "token " }, + consoleLinks: { keys: "https://app.dittowords.com/developers" }, + }); + expect(apiKey!.credentialFields).toEqual([ + expect.objectContaining({ key: "authorization", type: "password", secret: true, required: true }), + ]); + expect(apiKey!.credentialFields?.[0]?.helperMd).toContain("set delete and link actions to Ask first"); + for (const method of ditto.methods) { + // The access step shows the profile description before sign-in. + expect(method.capabilityProfile).toMatchObject({ + key: "write", + description: expect.stringContaining("cannot be undone"), + }); + expect(method.tenantFields ?? []).toEqual([]); + expect(method.warnings).toHaveLength(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({ diff --git a/packages/shared/src/app-definitions/ditto.json b/packages/shared/src/app-definitions/ditto.json new file mode 100644 index 00000000000..5976f435edf --- /dev/null +++ b/packages/shared/src/app-definitions/ditto.json @@ -0,0 +1,97 @@ +{ + "schemaVersion": 1, + "slug": "ditto", + "name": "Ditto", + "description": "Search and reuse approved product copy, follow your style guide, and edit and translate text.", + "categories": [ + "content" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/ditto.png" + }, + "urlPatterns": [ + "https://api.dittowords.com/*" + ], + "docsUrl": "https://developer.dittowords.com/guides/connect-the-mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://api.dittowords.com/v2/mcp", + "scopesHint": [ + "openid", + "profile", + "email", + "offline_access" + ] + }, + "guidanceMd": "Connect Ditto in the browser. The connection acts as the Ditto user who signs in. Write tools start enabled and remain governed by Paperclip's action policies.", + "riskTier": "S4", + "label": "Sign in with Ditto", + "consoleLinks": { + "docs": "https://developer.dittowords.com/guides/connect-the-mcp" + }, + "warnings": [ + "A Ditto account in the workspace whose text and style guides agents should use. API keys require developer integrations on the Ditto plan.", + "Ditto delete tools cannot be undone, and linking text to a library component replaces each text item's developer ID. Set those actions to Ask first before agents run unattended." + ], + "capabilityProfile": { + "key": "write", + "label": "Read and write", + "description": "Read style guide rules and search product text, and create, edit and translate text, components, variants and style guides. Ditto delete tools cannot be undone, and linking text to a library component replaces each text item's developer ID. Set those actions to Ask first before agents run unattended." + } + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a Ditto workspace API key when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://api.dittowords.com/v2/mcp" + }, + "guidanceMd": "In Ditto, open Developers → API keys, create a key for Paperclip, and paste it below.", + "riskTier": "S4", + "label": "Use an API key", + "credentialFields": [ + { + "key": "authorization", + "label": "Ditto API key", + "type": "password", + "required": true, + "placeholder": "Paste your Ditto API key", + "secret": true, + "helperMd": "Ditto API keys are not scoped: a key reaches all data in its workspace, including permanent deletes, and actions are attributed to the user who created the key. Create a separate key for Paperclip so it can be removed independently, and set delete and link actions to Ask first before agents run unattended. Paperclip cannot narrow an existing key." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "token " + }, + "consoleLinks": { + "keys": "https://app.dittowords.com/developers", + "docs": "https://developer.dittowords.com/guides/connect-the-mcp" + }, + "warnings": [ + "A Ditto account in the workspace whose text and style guides agents should use. API keys require developer integrations on the Ditto plan.", + "Ditto delete tools cannot be undone, and linking text to a library component replaces each text item's developer ID. Set those actions to Ask first before agents run unattended." + ], + "capabilityProfile": { + "key": "write", + "label": "Read and write", + "description": "Read style guide rules and search product text, and create, edit and translate text, components, variants and style guides. Ditto delete tools cannot be undone, and linking text to a library component replaces each text item's developer ID. Set those actions to Ask first before agents run unattended." + } + } + ] +} diff --git a/packages/shared/src/self-serve-mcp-research.json b/packages/shared/src/self-serve-mcp-research.json index 25c89d032c0..1f2e624aad3 100644 --- a/packages/shared/src/self-serve-mcp-research.json +++ b/packages/shared/src/self-serve-mcp-research.json @@ -68,6 +68,7 @@ { "slug": "clickup", "name": "ClickUp", "wave": 1, "status": "self_serve", "docsUrl": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server", "serverUrl": "https://mcp.clickup.com/mcp", "authMode": "dcr", "prerequisite": "Sign in to a ClickUp account and select only the intended workspace. Actions remain bounded by the signed-in user’s ClickUp permissions; MCP calls may be subject to workspace-plan rate limits.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "blocked-login", "liveRead": "not-run", "blocker": "Fresh and retained Google sign-in both returned to the blank ClickUp login form with Password required" }, { "slug": "klaviyo", "name": "Klaviyo", "wave": 2, "status": "self_serve", "docsUrl": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server", "serverUrl": "https://mcp.klaviyo.com/mcp?toolsets=campaigns:read,campaigns:write,metrics:read,segments:read", "authMode": "dcr", "prerequisite": "Hosted MCP requires a Klaviyo Owner, Admin, or Manager role. The server's toolsets parameter selects campaign read/write, metrics read, and segment read catalog families; exact authenticated actions still need review.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "pending-client-approval", "liveRead": "not-run", "blocker": "Initial client authorization awaits user approval; account and scopes not yet enumerated" }, { "slug": "monday", "name": "monday.com", "wave": 2, "status": "blocked", "docsUrl": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp", "serverUrl": "https://mcp.monday.com/mcp", "authMode": "provider_approval", "prerequisite": "Provider DCR rejects the Paperclip HTTPS callback with HTTP 400: redirect_uris are not allowed. Contact monday.com to allowlist the callback before OAuth setup; account-admin approval may also be needed.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "blocked-callback-allowlist", "liveRead": "not-run", "blocker": "Provider callback allowlisting required" }, - { "slug": "context7", "name": "Context7", "wave": 2, "status": "self_serve", "docsUrl": "https://context7.com/docs/api-guide", "serverUrl": "https://mcp.context7.com/mcp", "authMode": "public", "prerequisite": "No account or API key is required for anonymous remote MCP use; the official docs state anonymous requests have low rate limits. An optional API key raises limits and supports private repositories, but this curated method does not request or store one.", "riskTier": "S1", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-public-no-auth", "liveRead": "passed: resolve-library-id and query-docs returned React public documentation; repeat query passed after reload with 0 agents selected", "liveWrite": "not-applicable", "blocker": null } + { "slug": "context7", "name": "Context7", "wave": 2, "status": "self_serve", "docsUrl": "https://context7.com/docs/api-guide", "serverUrl": "https://mcp.context7.com/mcp", "authMode": "public", "prerequisite": "No account or API key is required for anonymous remote MCP use; the official docs state anonymous requests have low rate limits. An optional API key raises limits and supports private repositories, but this curated method does not request or store one.", "riskTier": "S1", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-public-no-auth", "liveRead": "passed: resolve-library-id and query-docs returned React public documentation; repeat query passed after reload with 0 agents selected", "liveWrite": "not-applicable", "blocker": null }, + { "slug": "ditto", "name": "Ditto", "wave": 4, "status": "self_serve", "docsUrl": "https://developer.dittowords.com/guides/connect-the-mcp", "serverUrl": "https://api.dittowords.com/v2/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Ditto account in the workspace whose text and style guides agents should use. API keys require developer integrations on the Ditto plan.", "riskTier": "S4" } ] } diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index 0e9018abf6b..f55fad80e9c 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -18,7 +18,7 @@ export type SelfServeMcpAuthMode = export interface SelfServeMcpResearchEntry { slug: string; name: string; - wave: 1 | 2 | 3 | "blocked"; + wave: 1 | 2 | 3 | 4 | "blocked"; status: "self_serve" | "blocked"; docsUrl: string; serverUrl: string; diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 9d6cb4b60fe..6eb77ca0ca9 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1014,6 +1014,7 @@ const categoryBySlug = { cloudflare: "developer", cloudinary: "content", coda: "productivity", + ditto: "content", egnyte: "content", embat: "commerce", fireflies: "productivity", @@ -1096,6 +1097,11 @@ const apiKeySpec = { prefix: "Bearer ", placeholder: "Paste your Cloudflare API token", }, + ditto: { + name: "Authorization", + prefix: "token ", + placeholder: "Paste your Ditto API key", + }, coda: { name: "Authorization", prefix: "Bearer ", @@ -1233,6 +1239,34 @@ const specialMethodsFor = (entry) => { ]; // Gauge's sign-in picks one organization during consent; an API key is bound // to the organization that created it and carries no user identity. + // Ditto's sign-in runs on Auth0, which issues refresh tokens only for + // offline_access. Its API keys use the `token` scheme, not `Bearer`, and act + // as the user who created them. + if (entry.slug === "ditto") { + const warning = + "Ditto delete tools cannot be undone, and linking text to a library component replaces each text item's developer ID. Set those actions to Ask first before agents run unattended."; + const capabilityProfile = { + key: "write", + label: "Read and write", + description: `Read style guide rules and search product text, and create, edit and translate text, components, variants and style guides. ${warning}`, + }; + return [ + oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, { + guidanceMd: + "Connect Ditto in the browser. The connection acts as the Ditto user who signs in. Write tools start enabled and remain governed by Paperclip's action policies.", + warnings: [entry.prerequisite, warning], + capabilityProfile, + }), + apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, { + whenToUse: "Use a Ditto workspace API key when browser sign-in is not suitable.", + guidanceMd: + "In Ditto, open Developers → API keys, create a key for Paperclip, and paste it below.", + consoleLinks: { keys: "https://app.dittowords.com/developers", docs: entry.docsUrl }, + warnings: [entry.prerequisite, warning], + capabilityProfile, + }), + ]; + } if (entry.slug === "gauge") { const warning = "Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended."; @@ -1783,7 +1817,7 @@ for (const entry of researchManifest.entries) { schemaVersion: 1, slug: entry.slug, name: entry.name, - description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", gauge: "Track how AI answers mention your brand, research keywords and traffic, and run content workflows.", superagent: "Review security findings, start red-team reports, and score content and packages before agents trust them.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers.", telem: "Search the web and read pages across many search providers with one API key." })[entry.slug] ?? (entry.slug === "fireflies" + description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", ditto: "Search and reuse approved product copy, follow your style guide, and edit and translate text.", gauge: "Track how AI answers mention your brand, research keywords and traffic, and run content workflows.", superagent: "Review security findings, start red-team reports, and score content and packages before agents trust them.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers.", telem: "Search the web and read pages across many search providers with one API key." })[entry.slug] ?? (entry.slug === "fireflies" ? "Search meeting transcripts, read summaries and action items, and connect meeting-ready routines." : `Connect ${entry.name}'s provider-hosted MCP server.`), categories: [categoryBySlug[entry.slug] ?? "other"], diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 20df4507b80..715e6ed2b14 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -6437,6 +6437,52 @@ describeEmbeddedPostgres("tool access service", () => { ).rejects.toMatchObject({ status: 400 }); }); + it("connects Ditto with a workspace API key sent with the token scheme", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const fetchMock = mockToolsList([ + { name: "search_ditto_text" }, + { name: "translate_text_items" }, + { name: "delete_text_items" }, + ]); + + const result = await service.connectGalleryApp( + company.id, + { + galleryKey: "ditto", + connectionMethodKey: "mcp-api-key", + credentialValues: { "credentials.authorization": "ditto-test-secret" }, + }, + { actorType: "user", actorId: "board" }, + ); + + // Ditto rejects the Bearer scheme for API keys; it expects `token `. + expect(fetchMock).toHaveBeenCalledWith( + "https://api.dittowords.com/v2/mcp", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "token ditto-test-secret", + }), + }), + ); + expect(result.connection).toMatchObject({ + authKind: "api_key", + config: { + url: "https://api.dittowords.com/v2/mcp", + sourceTemplateKey: "ditto", + connectionMethodKey: "mcp-api-key", + }, + }); + expect(JSON.stringify(result.connection.config)).not.toContain("ditto-test-secret"); + expect(result.catalog).toEqual( + expect.arrayContaining([ + expect.objectContaining({ toolName: "search_ditto_text", riskLevel: "read", status: "active" }), + expect.objectContaining({ toolName: "translate_text_items", riskLevel: "write", status: "active" }), + expect.objectContaining({ toolName: "delete_text_items", riskLevel: "destructive", status: "active" }), + ]), + ); + }); + it("connects Superagent with an organization API key sent as a bearer header", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); @@ -20134,6 +20180,35 @@ describe("classifyRisk", () => { expect(classifyRisk({ name: "triage_finding" })).toBe("read"); }); + it("classifies Ditto translate, link and unlink tools as writes", () => { + for (const name of [ + "get_styleguide_rules", + "suggest_edit", + "search_ditto_text", + "list_projects", + "list_variants", + "get_workspace_tags", + ]) + expect(classifyRisk({ name }, "ditto")).toBe("read"); + for (const name of [ + "translate_text_items", + "translate_library_components", + "link_figma_nodes_to_text_item", + "link_text_items_to_component", + "unlink_text_items_from_component", + "publish_library_component", + "create_text_items", + "update_styleguide_rules", + ]) + expect(classifyRisk({ name }, "ditto")).toBe("write"); + for (const name of ["delete_text_items", "delete_styleguide_rules", "delete_variants"]) + expect(classifyRisk({ name }, "ditto")).toBe("destructive"); + expect(classifyRisk({ name: "list_projects", annotations: { readOnlyHint: false } }, "ditto")).toBe("write"); + // Without the provider rule, the generic classifier treats these as reads. + expect(classifyRisk({ name: "translate_text_items" })).toBe("read"); + expect(classifyRisk({ name: "link_text_items_to_component" })).toBe("read"); + }); + it("classifies Enterpret run_graph_query as write despite readOnlyHint", () => { expect( classifyRisk( diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index c3c2c6b0b55..8dbc93a84d5 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2450,6 +2450,19 @@ export function classifyRisk( if (annotations.readOnlyHint === false || annotations.writeHint === true) return "write"; return /^(list|get)-/.test(normalizedToolName) || annotations.readOnlyHint === true ? "read" : "write"; } + // Ditto's translate, link and unlink tools replace saved text or developer + // IDs, but the generic classifier reads those verbs as reads. Only + // list/get/search tools, suggest_edit, and tools Ditto marks read-only are + // reads. + if (sourceTemplateKey === "ditto") { + if (verbMatches(tool.name, "delete|remove|destroy")) return "destructive"; + if (annotations.readOnlyHint === false || annotations.writeHint === true) return "write"; + return /^(list|get|search)-/.test(normalizedToolName) || + normalizedToolName === "suggest-edit" || + annotations.readOnlyHint === true + ? "read" + : "write"; + } if ( sourceTemplateKey === "shopify" && SHOPIFY_DESTRUCTIVE_TOOLS.has(normalizedToolName) diff --git a/ui/public/brands/apps/ditto.png b/ui/public/brands/apps/ditto.png new file mode 100644 index 00000000000..8c38e880e24 Binary files /dev/null and b/ui/public/brands/apps/ditto.png differ diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 1710fb254a4..a6c9e5c8e88 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -116,6 +116,12 @@ "localAsset": "/brands/apps/discord.svg", "darkAsset": "/brands/apps/discord-dark.svg" }, + { + "slug": "ditto", + "provider": "Ditto", + "catalogVisible": true, + "localAsset": "/brands/apps/ditto.png" + }, { "slug": "egnyte", "provider": "Egnyte", diff --git a/ui/src/lib/app-gallery-copy.ts b/ui/src/lib/app-gallery-copy.ts index 8594e47514a..eafbc9391d4 100644 --- a/ui/src/lib/app-gallery-copy.ts +++ b/ui/src/lib/app-gallery-copy.ts @@ -74,6 +74,10 @@ const APP_COPY: Record = { tagline: "Explore product usage, errors, flags, and experiments.", short: "Sign in with PostHog. Project pinning and access controls are optional.", }, + ditto: { + tagline: "Reuse approved product copy and follow your style guide.", + short: "Sign in with Ditto. API keys are optional.", + }, gauge: { tagline: "Track your brand in AI answers and run content workflows.", short: "Sign in with Gauge and pick an organization. API keys are optional.", diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 551c025251d..71b107f22b7 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -66,6 +66,7 @@ const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog" const NEON = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "neon")!; const SUPERAGENT = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "superagent")!; const GAUGE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gauge")!; +const DITTO = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "ditto")!; const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!; const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!; const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; @@ -2182,6 +2183,44 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { })); }); + it("defaults Ditto to browser sign-in and connects a workspace API key once entered", async () => { + mockParams.appKey = "ditto"; + listGalleryMock.mockResolvedValueOnce({ apps: [DITTO] }); + await render(); + // The delete warning must be visible on the default sign-in path. + expect(container.textContent).toContain("delete tools cannot be undone"); + expect(buttonByText("Continue to sign in")?.disabled).toBe(false); + await openAccessAdvanced(); + + expect(radioContaining("Sign in with Ditto")?.getAttribute("aria-checked")).toBe("true"); + await act(async () => { + radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain("set delete and link actions to Ask first"); + const keyInput = container.querySelector('input[type="password"]'); + expect(keyInput).toBeTruthy(); + expect(buttonByText("Connect")?.disabled).toBe(true); + + await act(async () => { + setInputValue(keyInput!, "ditto-test-key"); + }); + await flushReact(); + const submit = buttonByText("Connect"); + expect(submit?.disabled).toBe(false); + await act(async () => { + submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "ditto", + connectionMethodKey: "mcp-api-key", + credentialValues: { "credentials.authorization": "ditto-test-key" }, + })); + }); + it("defaults Gauge to browser sign-in and connects an organization API key once entered", async () => { mockParams.appKey = "gauge"; listGalleryMock.mockResolvedValueOnce({ apps: [GAUGE] });