@vialytics/dittomato-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +317 -0
  2. package/index.js +996 -0
  3. package/package.json +44 -0
package/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # @vialytics/dittomato-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server over a Dittomato backend, so
4
+ an agent in any MCP-capable editor — Claude Code, Claude Desktop, Cursor, VS
5
+ Code — can search, read and write UI strings without leaving the codebase it's
6
+ editing.
7
+
8
+ The workflow it's for: *"is there already a string for 'Apply filter'?"* →
9
+ *"add `filter.reset` and translate it into German and French"* — answered in
10
+ the editor, against the live catalog, instead of tab-switching to the web
11
+ editor and copying IDs by hand.
12
+
13
+ It's a thin adapter over the same Ditto-compatible HTTP API the CLI and the
14
+ Figma plugin use (see [docs/API.md](../docs/API.md)). No Firebase SDK, no
15
+ service account — just a host and a `dsk_` token, the same config contract as
16
+ [`dittomato-pull`](../cli/).
17
+
18
+ ## Setup
19
+
20
+ Get a token first. The app's **Keys** panel gives you a personal token, which
21
+ inherits your own role — so it can write if you're an editor or admin. That's
22
+ the one you want for your own editor.
23
+
24
+ For a shared/service token, an admin issues one from Settings → **Team API
25
+ tokens** with access `readwrite`. Note `node tokens.js mint "<label>"` is **read-only** —
26
+ service tokens minted from the CLI have no access level and behave as `read`, so
27
+ the write tools will 403. Avoid `write`-only tokens here: they can't read at
28
+ all, so search and get fail while pushes succeed.
29
+
30
+ ### Claude Desktop — one click (`.mcpb`)
31
+
32
+ The repo ships a prebuilt extension bundle: **[`mcp/dittomato-mcp.mcpb`](./dittomato-mcp.mcpb)**
33
+ (~175 KB). Download it, then drag it onto Claude Desktop, or use Settings →
34
+ **Extensions** → *Install extension…*. Claude Desktop asks for four values at
35
+ install time — no config file to edit, no Node install, no `npx` on first run:
36
+
37
+ | Field | |
38
+ | --- | --- |
39
+ | **API Host** | `https://europe-west3-<project>.cloudfunctions.net/api` |
40
+ | **API Key** | your own `dsk_…` token from the Keys panel |
41
+ | **Read-only** | off by default; on hides the write tools |
42
+ | **Allow delete** | off by default; on exposes `delete_strings` |
43
+
44
+ The token is entered per person and stored by Claude Desktop, so the bundle in
45
+ git carries no credentials — everyone installs the same file with their own key
46
+ and their own role.
47
+
48
+ The bundle is a single esbuild-bundled `index.js`; it carries no `node_modules`
49
+ and needs only Node ≥ 18 on the machine. It's signed with a **self-signed**
50
+ vialytics certificate (`CN=vialytics`), which is tamper-evidence, not a trust
51
+ chain — Claude Desktop still shows it as an unverified publisher, same as any
52
+ extension not signed by a CA.
53
+
54
+ ### Claude Code
55
+
56
+ ```bash
57
+ claude mcp add dittomato \
58
+ --env DITTO_API_HOST=https://europe-west3-your-project.cloudfunctions.net/api \
59
+ --env DITTO_API_KEY=dsk_xxx \
60
+ -- npx -y @vialytics/dittomato-mcp
61
+ ```
62
+
63
+ ### Claude Desktop / Cursor / VS Code — manual config
64
+
65
+ Add to the client's MCP config (`claude_desktop_config.json`, `.cursor/mcp.json`,
66
+ `.vscode/mcp.json` — the shape is the same):
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "dittomato": {
72
+ "command": "npx",
73
+ "args": ["-y", "@vialytics/dittomato-mcp"],
74
+ "env": {
75
+ "DITTO_API_HOST": "https://europe-west3-your-project.cloudfunctions.net/api",
76
+ "DITTO_API_KEY": "dsk_xxx"
77
+ }
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ | Env var | |
84
+ | --- | --- |
85
+ | `DITTO_API_HOST` | API base URL (required) |
86
+ | `DITTO_API_KEY` | `dsk_…` token (required; `DITTOMATO_TOKEN` also accepted) |
87
+ | `DITTO_MCP_READONLY=1` | Drop the write tools from the server entirely |
88
+ | `DITTO_MCP_ALLOW_DELETE=1` | Expose `delete_strings`. Off by default — see below |
89
+
90
+ `--host`, `--token`, `--readonly` and `--allow-delete` work as flags too, but env
91
+ vars keep the token out of process listings and client config diffs.
92
+ `DITTO_MCP_READONLY` wins over `DITTO_MCP_ALLOW_DELETE` if both are set.
93
+
94
+ ## Tools
95
+
96
+ **Read**
97
+
98
+ | Tool | |
99
+ | --- | --- |
100
+ | `list_variants` | Which locales exist |
101
+ | `search_strings` | Substring search over IDs and every locale's text |
102
+ | `get_strings` | Full detail for specific IDs: plurals, translations, missing locales |
103
+ | `translation_coverage` | How much of a prefix is translated, plus the missing IDs |
104
+ | `export_locale` | A slice of the catalog as a flat i18next map |
105
+ | `list_variables` | The `{{Variable}}` placeholders and their examples |
106
+ | `review_copy` | The team's AI style-guide check on one string |
107
+ | `suggest_ids` | Convention-following ID naming, and reuse detection |
108
+
109
+ **Write** (hidden entirely under `DITTO_MCP_READONLY=1`)
110
+
111
+ | Tool | |
112
+ | --- | --- |
113
+ | `create_strings` | New base English strings |
114
+ | `update_strings` | Change text or plural forms for one locale |
115
+
116
+ **Delete** — off by default, needs `DITTO_MCP_ALLOW_DELETE=1` as well
117
+
118
+ | Tool | |
119
+ | --- | --- |
120
+ | `delete_strings` | Delete entries, or clear one locale's translation |
121
+
122
+ **Context** — for doing the work yourself instead of paying for a second model
123
+
124
+ | Tool | |
125
+ | --- | --- |
126
+ | `get_instructions` | The team's style guide / translation instructions / ID conventions |
127
+ | `get_glossary` | Canonical EN/DE/FR terminology, filtered to a given text |
128
+ | `get_naming_context` | Namespaces, containers, existing IDs, same-text reuse candidates |
129
+
130
+ `review_copy` and `suggest_ids` are read-shaped but spend the server-held
131
+ Anthropic key, so they need a token with write access.
132
+
133
+ ## Don't pay for a second Claude
134
+
135
+ You're already talking to one. `review_copy` and `suggest_ids` send the work to
136
+ the Cloud Function, which calls Claude again — billed to the team's shared
137
+ Anthropic key, two hops away, judging with none of your conversation's context.
138
+ Those endpoints exist because the Figma plugin and the web editor have no model
139
+ of their own and genuinely need them.
140
+
141
+ The context tools serve the same inputs so the judging can happen where the
142
+ context already is:
143
+
144
+ | Instead of | Use |
145
+ | --- | --- |
146
+ | `review_copy` | `get_instructions(kind="review")` + `get_glossary({text})` |
147
+ | `suggest_ids` | `get_instructions(kind="naming")` + `get_naming_context({prefix, texts})` |
148
+
149
+ This stays consistent with the other surfaces, because those are the same
150
+ admin-managed Firestore documents `/review-copy` and `/suggest-ids` read — a
151
+ wording fix in Settings → Translation instructions governs your judgment too.
152
+ Both prompts (`harvest_screen`, `translate_gaps`) take this path by default.
153
+
154
+ Two things you give up, worth knowing:
155
+
156
+ - **`ai_usage` and `ai_feedback` don't see it.** No token telemetry for work done
157
+ locally, and the thumbs up/down loop that tunes the server-side prompts never
158
+ learns from it.
159
+ - **`suggest_ids`' server-side validation.** It verifies a proposed `reuseId`
160
+ actually exists, converts a colliding new ID into a reuse, and recomputes
161
+ `newNamespace` rather than trusting the model. Naming locally loses that,
162
+ though `create_strings` still refuses an ID that already exists.
163
+
164
+ ## Prompts: harvesting a screen
165
+
166
+ Two multi-step flows ship as MCP prompts, which clients surface as slash
167
+ commands (`/mcp__dittomato__harvest_screen` in Claude Code):
168
+
169
+ | Prompt | |
170
+ | --- | --- |
171
+ | `harvest_screen` | Read the strings off a design, name them, catch duplicates, create the new ones |
172
+ | `translate_gaps` | Find untranslated strings for a locale and fill them, glossary-checked |
173
+
174
+ `harvest_screen` is the "paste a screenshot / point at a Figma frame" workflow:
175
+
176
+ 1. **You** hand the agent an image, a Figma URL, or a code file.
177
+ 2. The agent reads the visible copy — off the image with its own vision, or via
178
+ a Figma MCP if one is connected (which also yields real layer/frame/page
179
+ names, better naming context than an image can give).
180
+ 3. `suggest_ids` triages the batch: **IGNORE** (sample data, not copy), **REUSE**
181
+ (already in the catalog — it checks every entry for same-text matches itself,
182
+ no prior search needed) or **CREATE** (a new convention-following ID).
183
+ 4. The agent shows you the verdicts before writing anything, flagging
184
+ `reuseConfidence: "semantic"` and `newNamespace: true` rows as judgement calls.
185
+ 5. `create_strings` writes only what you approved.
186
+
187
+ Note where the vision happens: **in your editor's model, not in this server.**
188
+ This server never sees the image. That's why it works with a pasted screenshot,
189
+ a Figma selection, a PDF, or a photo of a whiteboard without needing to know
190
+ about any of them — it takes over once there's a list of strings.
191
+
192
+ The prompts are plain text in [index.js](index.js) — worth editing if your team's
193
+ review step differs.
194
+
195
+ ## It holds no catalog
196
+
197
+ The first version cached the whole catalog in memory, because the API had no
198
+ server-side search. Measured, that was **2.25 MB across 11,162 entries, served
199
+ uncompressed** — so every session paid ~2 s before its first answer and re-paid
200
+ it whenever the cache expired, while the search itself took 2–3 ms. The catalog
201
+ was never the expensive part; shipping it was.
202
+
203
+ `/v2/search`, `/v2/entries` and `/v2/coverage` now do that work server-side, off
204
+ the function's own cached index, next to Firestore:
205
+
206
+ | | Before | Now |
207
+ | --- | --- | --- |
208
+ | Startup | 2.25 MB, ~2 s | nothing fetched |
209
+ | A search | 2.25 MB, then 2 ms | ~2.4 KB |
210
+ | Coverage | same 2.25 MB | 485 B |
211
+ | One entry | same 2.25 MB | 236 B |
212
+
213
+ Startup is instant, there's nothing to invalidate after a write, and memory is
214
+ flat regardless of how big the catalog gets. Only the variant list — a handful of
215
+ rows — is cached, for five minutes.
216
+
217
+ ## What it still adds over raw curl
218
+
219
+ **Bounded payloads.** Every tool caps its result and reports when it truncated,
220
+ so a search can't dump the catalog into the model's context. Results are compact
221
+ JSON, not pretty-printed — indentation alone measured 27% of a search payload.
222
+
223
+ **It refuses writes the API would accept but that lose data.** `POST
224
+ /v2/components` sets `{base, name}` wholesale at the entry key, so "creating" an
225
+ ID that already exists silently drops its `de`/`fr` translations —
226
+ `create_strings` looks the IDs up first and rejects the whole batch rather than
227
+ writing part of it. IDs are validated against the same `ID_RE` the server uses,
228
+ and plural keys against the CLDR form list, before anything is sent.
229
+
230
+ **A two-step delete.** Preview the loss, then confirm — see below.
231
+
232
+ For a full export of every locale to disk, don't use `export_locale` — run
233
+ `npx dittomato-pull`, which writes the files directly without routing megabytes
234
+ through a model.
235
+
236
+ ## Requires a current backend
237
+
238
+ The read tools depend on `/v2/search`, `/v2/entries` and `/v2/coverage`. Against
239
+ a Cloud Function deployed before those existed every read returns `404`, and the
240
+ error message says so. Deploy the function first.
241
+
242
+ ## Note on writes
243
+
244
+ The write tools change the live shared catalog. MCP clients prompt before a tool
245
+ call, which is the real gate — but if you're handing this to a wider team,
246
+ `DITTO_MCP_READONLY=1` (or just issuing `read` tokens) removes the question.
247
+ Every write is attributed to the token's owner in the `changelog` collection and
248
+ shows up in the web editor's history, same as an edit made in the UI.
249
+
250
+ ## Deleting
251
+
252
+ `delete_strings` is gated behind its own flag and **absent from the tool list
253
+ without it**, because it's the one operation here you can't walk back:
254
+
255
+ - Deleting an entry takes its base text, every translation and every plural form
256
+ with it. There's no soft delete and no undo in the API. Recovery means the
257
+ nightly snapshot (`backups/{YYYY-MM-DD}`, 30 days, `node restore.js <date>`),
258
+ which costs a day of everyone else's edits.
259
+ - **It can't check whether a string is still used.** The web editor's delete flow
260
+ checks GitHub code usage and Figma usages first; neither read is reachable with
261
+ a `dsk_` token, so this tool can't do it. Its response carries that caveat so
262
+ the agent repeats it to you, but the check itself is on you — in the editor.
263
+
264
+ The tool refuses to write unless called with `confirm: true`. Called without it,
265
+ it returns a preview of exactly what would be lost — including which locales the
266
+ entry currently has — and touches nothing. That two-step is the analogue of the
267
+ editor's type-the-ID confirmation: it forces the losses in front of a human
268
+ before the destructive call.
269
+
270
+ Pass `locale` to clear one translation instead of deleting the entry. That's the
271
+ strictly narrower operation and usually the one you actually want when a
272
+ translation is wrong rather than unwanted.
273
+
274
+ ## Development
275
+
276
+ ```bash
277
+ cd mcp && yarn install && yarn test
278
+ ```
279
+
280
+ To try it against a real backend without publishing anything, point your MCP
281
+ client straight at the checkout — no `npx`, no install:
282
+
283
+ ```bash
284
+ claude mcp add dittomato-local \
285
+ --env DITTO_API_HOST=https://europe-west3-your-project.cloudfunctions.net/api \
286
+ --env DITTO_API_KEY=dsk_xxx \
287
+ -- node /absolute/path/to/dittomato/mcp/index.js
288
+ ```
289
+
290
+ `npm pack --dry-run` shows exactly which files a publish would ship.
291
+
292
+ The tests run against a mock backend serving the shapes
293
+ [docs/API.md](../docs/API.md) documents, and include an end-to-end stdio
294
+ handshake so an SDK bump can't silently break the protocol wiring.
295
+
296
+ ### Rebuilding the `.mcpb`
297
+
298
+ ```bash
299
+ cd mcp
300
+ npm run signing:init # once — writes .signing/{cert,key}.pem, both gitignored
301
+ npm run build:mcpb # bundle → pack → sign, writes dittomato-mcp.mcpb
302
+ ```
303
+
304
+ `build:bundle` runs esbuild over `index.js` and copies `manifest.json` and this
305
+ README into `build/`; `build:pack` zips that directory; `build:sign` appends the
306
+ PKCS#7 block. Commit the resulting `dittomato-mcp.mcpb` — it's the file the team
307
+ downloads. `build/` and `.signing/` stay out of git.
308
+
309
+ Bump `version` in **both** `package.json` and `manifest.json` before rebuilding;
310
+ Claude Desktop reads the manifest's version, and a bundle whose version didn't
311
+ change won't look like an update.
312
+
313
+ Note `mcpb verify` and `mcpb info` report *"Not signed"* on a correctly signed
314
+ bundle: node-forge, which the CLI uses, throws `PKCS#7 signature verification
315
+ not yet implemented` and the CLI swallows that into an unsigned verdict. The
316
+ signature is there — `openssl asn1parse` over the trailing `MCPB_SIG_V1` block
317
+ shows it. Don't chase that warning.
package/index.js ADDED
@@ -0,0 +1,996 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dittomato-mcp — an MCP server over a Dittomato backend, so an agent in any
4
+ * MCP-capable editor (Claude Code, Claude Desktop, Cursor, VS Code, …) can
5
+ * search, read and write UI strings without leaving the codebase it's editing.
6
+ *
7
+ * A thin adapter over the same Ditto-compatible HTTP API the CLI and the Figma
8
+ * plugin use — see docs/API.md. No Firebase SDK, no service account: just a
9
+ * host and a `dsk_` token, same config contract as `dittomato-pull`.
10
+ *
11
+ * DITTO_API_HOST https://europe-west3-<project>.cloudfunctions.net/api
12
+ * DITTO_API_KEY dsk_… (DITTOMATO_TOKEN also accepted)
13
+ * DITTO_MCP_READONLY=1 hide the write tools entirely
14
+ * DITTO_MCP_ALLOW_DELETE=1 expose delete_strings (off by default)
15
+ *
16
+ * ── Why there is no catalog cache here ────────────────────────────────────────
17
+ * The first version fetched `/v1/components?format=structured` once and answered
18
+ * search/get/coverage from memory, because the API had no server-side search.
19
+ * Measured, that was 2.25MB across 11,162 entries, served UNCOMPRESSED, so every
20
+ * session paid ~2s before its first answer and re-paid it whenever the cache
21
+ * expired — while the search itself took 2-3ms. The catalog was never the
22
+ * interesting part; shipping it was.
23
+ *
24
+ * `/v2/search`, `/v2/entries` and `/v2/coverage` now do that work in-region next
25
+ * to Firestore, off the server's own cached index, and answer in about a
26
+ * kilobyte. So this process holds no catalog at all: startup is instant, there's
27
+ * nothing to invalidate after a write, and memory is flat regardless of catalog
28
+ * size. Only the variant list (a handful of rows) is cached, briefly.
29
+ *
30
+ * What this layer still adds over raw curl:
31
+ * - Bounded payloads. Every tool caps its result and says when it truncated, so
32
+ * a search can't dump the catalog into the model's context.
33
+ * - Refusing writes the API would accept but that lose data. POST
34
+ * /v2/components sets {base, name} wholesale at the entry key (see
35
+ * createComponents), so "creating" an existing ID silently drops its de/fr
36
+ * translations — checked here first. IDs are validated against the same
37
+ * ID_RE the server uses, plural keys against the CLDR form list.
38
+ * - A two-step delete: preview the loss, then confirm.
39
+ *
40
+ * stdio protocol note: stdout carries JSON-RPC frames and nothing else. All
41
+ * diagnostics go to stderr.
42
+ */
43
+
44
+ "use strict";
45
+
46
+ const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
47
+ const {
48
+ StdioServerTransport,
49
+ } = require("@modelcontextprotocol/sdk/server/stdio.js");
50
+ const {
51
+ ListToolsRequestSchema,
52
+ CallToolRequestSchema,
53
+ ListPromptsRequestSchema,
54
+ GetPromptRequestSchema,
55
+ } = require("@modelcontextprotocol/sdk/types.js");
56
+
57
+ const { version: VERSION } = require("./package.json");
58
+
59
+ // ── config ────────────────────────────────────────────────────────────────────
60
+
61
+ function parseArgs(argv) {
62
+ const args = {};
63
+ for (let i = 0; i < argv.length; i++) {
64
+ if (argv[i] === "--host") args.host = argv[++i];
65
+ else if (argv[i] === "--token") args.token = argv[++i];
66
+ else if (argv[i] === "--readonly") args.readonly = true;
67
+ else if (argv[i] === "--allow-delete") args.allowDelete = true;
68
+ }
69
+ return args;
70
+ }
71
+
72
+ const argv = parseArgs(process.argv.slice(2));
73
+ const HOST = (argv.host || process.env.DITTO_API_HOST || "").replace(/\/$/, "");
74
+ const TOKEN =
75
+ argv.token || process.env.DITTO_API_KEY || process.env.DITTOMATO_TOKEN || "";
76
+ const READONLY = !!argv.readonly || process.env.DITTO_MCP_READONLY === "1";
77
+ // Delete is opt-in separately from create/update, and off by default. It's the
78
+ // one irreversible operation here — removing an entry takes every translation
79
+ // with it — and the web editor gates its own delete behind a type-the-ID
80
+ // confirmation plus a code-usage check. Most installs have no reason to expose
81
+ // it at all, so the safe default is "the tool doesn't exist".
82
+ const ALLOW_DELETE =
83
+ !READONLY &&
84
+ (!!argv.allowDelete || process.env.DITTO_MCP_ALLOW_DELETE === "1");
85
+
86
+ // Mirrors functions/index.js ID_RE — lowercase dot-segmented, hyphens allowed.
87
+ const ID_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
88
+ const ID_MAX = 80;
89
+ // CLDR forms, same list as figma-plugin/ui.html PLURAL_FORMS and editor.js
90
+ // PLURAL_ORDER. Kept here so a typo'd form is rejected before it reaches
91
+ // Firestore, where it would become a permanent junk key in the plural map.
92
+ const PLURAL_FORMS = ["zero", "one", "two", "few", "many", "other"];
93
+
94
+ // ── HTTP ──────────────────────────────────────────────────────────────────────
95
+
96
+ class ApiError extends Error {}
97
+
98
+ // Batch caps are refused, never silently trimmed: an agent that asked to write
99
+ // 150 strings and got 100 back with no complaint has no way to notice the 50.
100
+ function requireBatch(value, field, max) {
101
+ const list = Array.isArray(value) ? value : [];
102
+ if (!list.length) throw new ApiError(`\`${field}\` must be a non-empty array.`);
103
+ if (list.length > max)
104
+ throw new ApiError(
105
+ `Too many items in \`${field}\`: ${list.length}, max ${max} per call. Split into batches.`,
106
+ );
107
+ return list;
108
+ }
109
+
110
+ async function api(pathname, { method = "GET", body, withHeaders } = {}) {
111
+ if (!HOST || !TOKEN)
112
+ throw new ApiError(
113
+ "Not configured: set DITTO_API_HOST and DITTO_API_KEY in the MCP server's env.",
114
+ );
115
+ const res = await fetch(HOST + pathname, {
116
+ method,
117
+ headers: {
118
+ Authorization: TOKEN,
119
+ // The catalog routes gzip ~6x. Node's fetch sends this by default and
120
+ // decompresses transparently; stated explicitly so it survives a runtime
121
+ // that doesn't.
122
+ "Accept-Encoding": "gzip",
123
+ ...(body ? { "Content-Type": "application/json" } : {}),
124
+ },
125
+ ...(body ? { body: JSON.stringify(body) } : {}),
126
+ });
127
+ if (!res.ok) {
128
+ const text = await res.text().catch(() => "");
129
+ let detail = text.slice(0, 300);
130
+ try {
131
+ const parsed = JSON.parse(text);
132
+ if (parsed && parsed.error) detail = parsed.error;
133
+ } catch {
134
+ /* not JSON — keep the raw excerpt */
135
+ }
136
+ // The API's status codes are meaningful (docs/API.md → Errors); translate
137
+ // the ones an agent can actually act on rather than surfacing a bare code.
138
+ if (res.status === 401)
139
+ throw new ApiError(
140
+ "Invalid API token (401). Check DITTO_API_KEY — it should be a dsk_… token.",
141
+ );
142
+ if (res.status === 403)
143
+ throw new ApiError(
144
+ `Forbidden (403): ${detail}. Reading needs a token with read or readwrite ` +
145
+ `access; writing needs readwrite, or a personal token owned by an editor/admin.`,
146
+ );
147
+ if (res.status === 404)
148
+ throw new ApiError(
149
+ `Not found (404): ${detail}. If this is a route the tool expects, the ` +
150
+ `backend may predate it — deploy the Cloud Function.`,
151
+ );
152
+ if (res.status === 501)
153
+ throw new ApiError(
154
+ `Not configured on the server (501): ${detail}. This endpoint's dependency ` +
155
+ `(e.g. the shared Anthropic key) isn't set up.`,
156
+ );
157
+ throw new ApiError(`${method} ${pathname} → HTTP ${res.status}: ${detail}`);
158
+ }
159
+ const json = await res.json();
160
+ return withHeaders ? { json, headers: res.headers } : json;
161
+ }
162
+
163
+ const qs = (params) => {
164
+ const sp = new URLSearchParams();
165
+ for (const [k, v] of Object.entries(params))
166
+ if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
167
+ return sp.toString();
168
+ };
169
+
170
+ // The variant list is a handful of rows and changes when someone adds a
171
+ // language, so a short TTL keeps a long session current without re-asking on
172
+ // every call. This is the only thing cached in this process.
173
+ const VARIANTS_TTL_MS = 5 * 60 * 1000;
174
+ let variantsCache = null;
175
+ let variantsAt = 0;
176
+ async function variants() {
177
+ if (!variantsCache || Date.now() - variantsAt >= VARIANTS_TTL_MS) {
178
+ variantsCache = await api("/v1/variants");
179
+ variantsAt = Date.now();
180
+ }
181
+ return variantsCache;
182
+ }
183
+ async function localeIds() {
184
+ return ["base", ...(await variants()).map((v) => v.id)];
185
+ }
186
+ async function assertLocale(locale) {
187
+ const all = await localeIds();
188
+ if (!all.includes(locale))
189
+ throw new ApiError(`Unknown locale '${locale}'. Known: ${all.join(", ")}.`);
190
+ }
191
+
192
+ // Existence + current content for specific IDs, straight from the server. This
193
+ // is what the write tools check against instead of a local catalog copy.
194
+ async function lookup(ids) {
195
+ const out = await api(`/v2/entries?${qs({ ids: ids.join(",") })}`);
196
+ const byId = new Map((out.strings || []).map((s) => [s.id, s]));
197
+ return { byId, notFound: out.notFound || [] };
198
+ }
199
+
200
+ // ── tools ─────────────────────────────────────────────────────────────────────
201
+
202
+ const READ_TOOLS = [
203
+ {
204
+ name: "list_variants",
205
+ description:
206
+ "List the locales this catalog carries (base plus every translation variant). Call first if you don't know the locale codes.",
207
+ inputSchema: { type: "object", properties: {} },
208
+ handler: async () => ({
209
+ locales: await localeIds(),
210
+ variants: (await variants()).map((v) => ({ id: v.id, name: v.name })),
211
+ }),
212
+ },
213
+
214
+ {
215
+ name: "search_strings",
216
+ description:
217
+ "Search the catalog by text or ID. Use before creating anything — duplicates are the most common mistake. Results are capped; narrow with id_prefix rather than raising the limit.",
218
+ inputSchema: {
219
+ type: "object",
220
+ properties: {
221
+ query: {
222
+ type: "string",
223
+ description:
224
+ "Case-insensitive substring, matched against the ID and against the text in every locale (or just `locale`).",
225
+ },
226
+ id_prefix: { type: "string", description: "Only IDs starting with this." },
227
+ locale: {
228
+ type: "string",
229
+ description: "Restrict text matching to this locale. Default: all.",
230
+ },
231
+ limit: { type: "integer", description: "Max results (default 25, max 100)." },
232
+ },
233
+ },
234
+ handler: async ({ query, id_prefix, locale, limit }) => {
235
+ if (!query && !id_prefix)
236
+ throw new ApiError("Pass at least one of `query` or `id_prefix`.");
237
+ if (locale) await assertLocale(locale);
238
+ return api(
239
+ `/v2/search?${qs({ q: query, prefix: id_prefix, locale, limit })}`,
240
+ );
241
+ },
242
+ },
243
+
244
+ {
245
+ name: "get_strings",
246
+ description:
247
+ "Full detail for specific IDs: base text, every translation, plural forms, and which locales are missing.",
248
+ inputSchema: {
249
+ type: "object",
250
+ properties: {
251
+ ids: {
252
+ type: "array",
253
+ items: { type: "string" },
254
+ description: "String IDs (max 100).",
255
+ },
256
+ },
257
+ required: ["ids"],
258
+ },
259
+ handler: async ({ ids }) =>
260
+ api(`/v2/entries?${qs({ ids: requireBatch(ids, "ids", 100).join(",") })}`),
261
+ },
262
+
263
+ {
264
+ name: "translation_coverage",
265
+ description:
266
+ "How much of the catalog (or one ID prefix) is translated into a locale, plus a sample of the missing IDs.",
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {
270
+ locale: { type: "string", description: "Locale to measure, e.g. 'de'." },
271
+ id_prefix: { type: "string", description: "Restrict to this prefix." },
272
+ sample_limit: {
273
+ type: "integer",
274
+ description: "How many missing IDs to list (default 50, max 200).",
275
+ },
276
+ },
277
+ required: ["locale"],
278
+ },
279
+ handler: async ({ locale, id_prefix, sample_limit }) =>
280
+ api(
281
+ `/v2/coverage?${qs({ locale, prefix: id_prefix, sample: sample_limit })}`,
282
+ ),
283
+ },
284
+
285
+ {
286
+ name: "export_locale",
287
+ description:
288
+ "Export a slice of the catalog as a flat i18next key→text map (plurals unfolded to key_one/key_other plus a bare key), ready to write to a file. For a FULL export of every locale, run `npx dittomato-pull` instead — it writes files directly without routing megabytes through the model.",
289
+ inputSchema: {
290
+ type: "object",
291
+ properties: {
292
+ locale: { type: "string", description: "Locale to export." },
293
+ id_prefix: {
294
+ type: "string",
295
+ description:
296
+ "Restrict to this prefix. Strongly recommended — the full catalog is far larger than the limit.",
297
+ },
298
+ limit: { type: "integer", description: "Max keys (default 200, max 1000)." },
299
+ },
300
+ required: ["locale"],
301
+ },
302
+ handler: async ({ locale, id_prefix, limit }) => {
303
+ await assertLocale(locale);
304
+ const cap = Math.min(Math.max(Number(limit) || 200, 1), 1000);
305
+ const { json, headers } = await api(
306
+ `/v1/components?${qs({
307
+ variant: locale === "base" ? "" : locale,
308
+ prefix: id_prefix,
309
+ limit: cap,
310
+ })}`,
311
+ { withHeaders: true },
312
+ );
313
+ const truncated = headers.get("x-truncated") === "true";
314
+ return {
315
+ locale,
316
+ idPrefix: id_prefix || null,
317
+ keysReturned: Object.keys(json).length,
318
+ totalKeys: Number(headers.get("x-total-keys")) || Object.keys(json).length,
319
+ truncated,
320
+ ...(truncated
321
+ ? {
322
+ note: "Truncated. Narrow with id_prefix, or run `npx dittomato-pull` for a full export to disk.",
323
+ }
324
+ : {}),
325
+ keys: json,
326
+ };
327
+ },
328
+ },
329
+
330
+ {
331
+ name: "list_variables",
332
+ description:
333
+ "The interpolation variables available to string text (the {{Variable}} placeholders), with example values.",
334
+ inputSchema: {
335
+ type: "object",
336
+ properties: {
337
+ query: { type: "string", description: "Substring filter on the name." },
338
+ limit: { type: "integer", description: "Max results (default 50, max 200)." },
339
+ },
340
+ },
341
+ handler: async ({ query, limit }) => {
342
+ const cap = Math.min(Math.max(Number(limit) || 50, 1), 200);
343
+ const vars = await api("/v2/variables");
344
+ const needle = query ? String(query).toLowerCase() : null;
345
+ const hits = vars.filter(
346
+ (v) => !needle || String(v.name).toLowerCase().includes(needle),
347
+ );
348
+ return {
349
+ totalMatches: hits.length,
350
+ truncated: hits.length > cap,
351
+ variables: hits
352
+ .slice(0, cap)
353
+ .map((v) => ({ name: v.name, type: v.type, data: v.data })),
354
+ };
355
+ },
356
+ },
357
+
358
+ // ── context tools ──────────────────────────────────────────────────────────
359
+ // You are already talking to a capable model. Calling review_copy or
360
+ // suggest_ids sends the work to a SECOND Claude on the server — billed to the
361
+ // team's shared Anthropic key, two hops away, and judging with none of this
362
+ // conversation's context. These serve the same admin-managed instructions,
363
+ // glossary and catalog slice so the judging can happen here instead.
364
+ {
365
+ name: "get_instructions",
366
+ description:
367
+ "Fetch the team's admin-managed instructions so YOU can do the work rather than paying for a second model to do it on the server. kind=review is the copy style guide, kind=translate the translation instructions, kind=naming the ID conventions and triage rules. These are the exact same documents /review-copy and /suggest-ids use, so following them keeps you consistent with the web editor and the Figma plugin. Fetch once and reuse for the rest of the conversation.",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ kind: {
372
+ type: "string",
373
+ enum: ["review", "translate", "naming"],
374
+ description: "Which instruction set to fetch.",
375
+ },
376
+ },
377
+ required: ["kind"],
378
+ },
379
+ handler: async ({ kind }) => api(`/v2/instructions?${qs({ kind })}`),
380
+ },
381
+
382
+ {
383
+ name: "get_glossary",
384
+ description:
385
+ "The team's canonical EN/DE/FR terminology. Pass `text` to get only the terms that text actually mentions — the same filter the server applies, which cuts ~99.8% because most UI strings mention no term. Omit `text` for the whole table (~184 terms) if you're about to work through many strings. Use these terms verbatim; they include product names that must never be translated or substituted.",
386
+ inputSchema: {
387
+ type: "object",
388
+ properties: {
389
+ text: {
390
+ type: "string",
391
+ description:
392
+ "Filter to terms this text mentions. Omit for the full table.",
393
+ },
394
+ },
395
+ },
396
+ handler: async ({ text }) => api(`/v2/glossary?${qs({ text })}`),
397
+ },
398
+
399
+ {
400
+ name: "get_naming_context",
401
+ description:
402
+ "The catalog slice needed to name new string IDs yourself: which namespaces exist and how big they are, the cross-cutting containers (toast./tooltip./modal./emptystate./…) with examples, everything already under a given prefix, and — per input text — existing entries with the SAME text, which are reuse candidates. Combine with get_instructions(kind=naming). Use `namespaceNames` (the full list) to judge whether a namespace is new; `namespaces` is ranked and truncated for readability.",
403
+ inputSchema: {
404
+ type: "object",
405
+ properties: {
406
+ prefix: {
407
+ type: "string",
408
+ description:
409
+ "Feature namespace you're naming into, e.g. 'checkout' — returns up to 40 existing IDs under it.",
410
+ },
411
+ texts: {
412
+ type: "array",
413
+ items: { type: "string" },
414
+ description:
415
+ "The string texts you're about to name (max 50). Each is checked against the whole catalog for identical existing text.",
416
+ },
417
+ },
418
+ },
419
+ handler: async ({ prefix, texts }) =>
420
+ api(
421
+ `/v2/naming-context?${qs({
422
+ prefix,
423
+ texts: (Array.isArray(texts) ? texts : []).slice(0, 50).join("\n"),
424
+ })}`,
425
+ ),
426
+ },
427
+
428
+ {
429
+ name: "review_copy",
430
+ description:
431
+ "Server-side AI style-guide check. Prefer doing this yourself with get_instructions(kind=review) + get_glossary — you have more context than the server-side model and it costs the team nothing. Use this when you want the same verdict the web editor and Figma plugin would produce, or a second opinion. Each issue carries a severity (error | warning | suggestion) and the result carries maxSeverity — gate on that, not on `ok`, which is true only when there are no issues at all and so treats a punctuation preference like a mistranslation. Spends the shared Anthropic key; needs a token with write access.",
432
+ inputSchema: {
433
+ type: "object",
434
+ properties: {
435
+ text: { type: "string", description: "The text to review." },
436
+ target_language: {
437
+ type: "string",
438
+ description:
439
+ "Set for a translation review, e.g. 'German'. Omit to review English base copy.",
440
+ },
441
+ base_text: {
442
+ type: "string",
443
+ description: "The English source, when reviewing a translation.",
444
+ },
445
+ siblings: {
446
+ type: "array",
447
+ description:
448
+ "Other strings from the SAME component ({id, text}), so drift between them can be flagged. Max 8.",
449
+ items: {
450
+ type: "object",
451
+ properties: { id: { type: "string" }, text: { type: "string" } },
452
+ },
453
+ },
454
+ },
455
+ required: ["text"],
456
+ },
457
+ handler: async ({ text, target_language, base_text, siblings }) =>
458
+ api("/review-copy", {
459
+ method: "POST",
460
+ body: {
461
+ text,
462
+ ...(target_language ? { targetLanguage: target_language } : {}),
463
+ ...(base_text ? { baseText: base_text } : {}),
464
+ ...(Array.isArray(siblings) && siblings.length
465
+ ? { siblings: siblings.slice(0, 8) }
466
+ : {}),
467
+ },
468
+ }),
469
+ },
470
+
471
+ {
472
+ name: "suggest_ids",
473
+ description:
474
+ "Server-side AI triage of strings read off a design: IGNORE (sample data), REUSE (an existing component means this) or CREATE (a new convention-following ID). Prefer naming them yourself with get_naming_context + get_instructions(kind=naming) — cheaper, and you can see the design and the code. Use this when you want the editor/plugin-consistent answer, or its server-side validation of reuse IDs and namespaces. Spends the shared Anthropic key.",
475
+ inputSchema: {
476
+ type: "object",
477
+ properties: {
478
+ prefix: {
479
+ type: "string",
480
+ description:
481
+ "Feature namespace hint, e.g. 'checkout'. Worth setting — it puts existing IDs from that namespace in front of the model.",
482
+ },
483
+ strings: {
484
+ type: "array",
485
+ description:
486
+ "Strings to triage (max 50). Include everything you read, even apparent sample data — deciding that is what IGNORE is for.",
487
+ items: {
488
+ type: "object",
489
+ properties: {
490
+ text: { type: "string" },
491
+ layer_name: { type: "string", description: "The design layer's name." },
492
+ page: { type: "string", description: "Design page, e.g. 'PCI Report'." },
493
+ frame: { type: "string", description: "Enclosing frame or component." },
494
+ candidates: {
495
+ type: "array",
496
+ items: { type: "string" },
497
+ description:
498
+ "IDs you believe this may duplicate (max 10). Supplying these makes it a disambiguation: the model must pick one or nothing. Exact-text matches are found server-side anyway.",
499
+ },
500
+ },
501
+ required: ["text"],
502
+ },
503
+ },
504
+ },
505
+ required: ["strings"],
506
+ },
507
+ handler: async ({ prefix, strings }) => {
508
+ const out = await api("/suggest-ids", {
509
+ method: "POST",
510
+ body: {
511
+ ...(prefix ? { prefix } : {}),
512
+ strings: requireBatch(strings, "strings", 50).map((s) => ({
513
+ text: String(s.text || ""),
514
+ ...(s.layer_name ? { layerName: String(s.layer_name) } : {}),
515
+ ...(s.page ? { page: String(s.page) } : {}),
516
+ ...(s.frame ? { frame: String(s.frame) } : {}),
517
+ ...(Array.isArray(s.candidates) && s.candidates.length
518
+ ? { candidates: s.candidates.slice(0, 10).map(String) }
519
+ : {}),
520
+ })),
521
+ },
522
+ });
523
+ // Spelled out in the RESULT rather than the tool description: this is
524
+ // ~120 tokens that only matters once a triage has actually run, and a
525
+ // description is re-sent on every turn whether the tool is used or not.
526
+ return {
527
+ ...out,
528
+ howToApply: {
529
+ ignore: "Not UI copy. Don't add it.",
530
+ reuse:
531
+ 'Use the existing `reuseId`; create nothing. reuseConfidence "exact" means identical catalog text and is safe to apply; "semantic" is meaning-level only — confirm with a human.',
532
+ create:
533
+ "Pass `suggestedId` to create_strings. `newNamespace: true` opens a new top-level namespace — worth confirming first.",
534
+ null: "Nothing valid was produced. Name it yourself or re-ask with more context.",
535
+ },
536
+ };
537
+ },
538
+ },
539
+ ];
540
+
541
+ const WRITE_TOOLS = [
542
+ {
543
+ name: "create_strings",
544
+ description:
545
+ "Create new base (English) strings. Refuses IDs that already exist, because overwriting an entry would discard its translations — search first. IDs are lowercase dot-segmented, e.g. 'checkout.cta.confirm'. If you're naming strings read off a design, run suggest_ids first. Writes to the live shared catalog and appears in the team's edit history.",
546
+ inputSchema: {
547
+ type: "object",
548
+ properties: {
549
+ strings: {
550
+ type: "array",
551
+ description: "Strings to create (max 100).",
552
+ items: {
553
+ type: "object",
554
+ properties: {
555
+ id: { type: "string", description: "Lowercase dot-segmented, max 80 chars." },
556
+ text: { type: "string", description: "The base English text." },
557
+ name: { type: "string", description: "Optional component name." },
558
+ },
559
+ required: ["id", "text"],
560
+ },
561
+ },
562
+ },
563
+ required: ["strings"],
564
+ },
565
+ handler: async ({ strings }) => {
566
+ const list = requireBatch(strings, "strings", 100);
567
+ const rejected = [];
568
+ const shaped = [];
569
+ for (const s of list) {
570
+ const id = String(s.id || "");
571
+ if (!id || !ID_RE.test(id) || id.length > ID_MAX)
572
+ rejected.push({
573
+ id,
574
+ reason:
575
+ "Invalid ID: lowercase letters/digits/hyphens in dot-separated segments, max 80 chars.",
576
+ });
577
+ else if (typeof s.text !== "string")
578
+ rejected.push({ id, reason: "`text` is required." });
579
+ else
580
+ shaped.push({
581
+ developerId: id,
582
+ text: s.text,
583
+ ...(s.name ? { name: String(s.name) } : {}),
584
+ });
585
+ }
586
+ // Only the shape-valid IDs are worth a round trip.
587
+ if (shaped.length) {
588
+ const { byId } = await lookup(shaped.map((c) => c.developerId));
589
+ for (const c of shaped.filter((c) => byId.has(c.developerId)))
590
+ rejected.push({
591
+ id: c.developerId,
592
+ reason: `Already exists (base text: ${JSON.stringify(
593
+ byId.get(c.developerId).base,
594
+ )}). Use update_strings, or pick a different ID — creating over it would erase its translations.`,
595
+ });
596
+ }
597
+ // All-or-nothing on validation: a partial create is the confusing case,
598
+ // where the agent believes it pushed N strings and N-2 landed.
599
+ if (rejected.length)
600
+ return { created: [], rejected, note: "Nothing was written." };
601
+ const out = await api("/v2/components", {
602
+ method: "POST",
603
+ body: { components: shaped },
604
+ });
605
+ return { created: (out.components || []).map((c) => c.id), rejected: [] };
606
+ },
607
+ },
608
+
609
+ {
610
+ name: "update_strings",
611
+ description:
612
+ "Update existing strings' text for one locale — base English, or a translation. Each update carries either `text` or `plurals`, never both.",
613
+ inputSchema: {
614
+ type: "object",
615
+ properties: {
616
+ locale: {
617
+ type: "string",
618
+ description: "Locale to write, e.g. 'de'. Omit (or 'base') for English.",
619
+ },
620
+ updates: {
621
+ type: "array",
622
+ description: "Updates to apply (max 100).",
623
+ items: {
624
+ type: "object",
625
+ properties: {
626
+ id: { type: "string" },
627
+ text: { type: "string", description: "Plain replacement text." },
628
+ plurals: {
629
+ type: "object",
630
+ description:
631
+ 'Full plural map, e.g. {"one": "1 item", "other": "{{count}} items"}. Keys must be CLDR forms: zero, one, two, few, many, other.',
632
+ additionalProperties: { type: "string" },
633
+ },
634
+ },
635
+ required: ["id"],
636
+ },
637
+ },
638
+ },
639
+ required: ["updates"],
640
+ },
641
+ handler: async ({ locale, updates }) => {
642
+ const list = requireBatch(updates, "updates", 100);
643
+ const target = !locale || locale === "base" ? "base" : String(locale);
644
+ await assertLocale(target);
645
+ const rejected = [];
646
+ const shaped = [];
647
+ for (const u of list) {
648
+ const id = String(u.id || "");
649
+ const hasText = typeof u.text === "string";
650
+ const hasPlurals = !!(u.plurals && typeof u.plurals === "object");
651
+ if (hasText === hasPlurals)
652
+ rejected.push({ id, reason: "Provide exactly one of `text` or `plurals`." });
653
+ else if (hasPlurals) {
654
+ const bad = Object.keys(u.plurals).filter((f) => !PLURAL_FORMS.includes(f));
655
+ if (bad.length)
656
+ rejected.push({
657
+ id,
658
+ reason: `Not CLDR plural forms: ${bad.join(", ")}. Allowed: ${PLURAL_FORMS.join(", ")}.`,
659
+ });
660
+ else shaped.push({ developerId: id, plurals: u.plurals });
661
+ } else shaped.push({ developerId: id, text: u.text });
662
+ }
663
+ if (shaped.length) {
664
+ const { notFound } = await lookup(shaped.map((u) => u.developerId));
665
+ for (const id of notFound)
666
+ rejected.push({ id, reason: "No such string. Use create_strings for new IDs." });
667
+ }
668
+ if (rejected.length)
669
+ return { updated: 0, rejected, note: "Nothing was written." };
670
+ const out = await api("/v2/components", {
671
+ method: "PATCH",
672
+ body: { variantId: target, updates: shaped },
673
+ });
674
+ return { locale: target, updated: out.updated, rejected: [] };
675
+ },
676
+ },
677
+ ];
678
+
679
+ const DELETE_TOOLS = [
680
+ {
681
+ name: "delete_strings",
682
+ description:
683
+ "Delete strings, or clear one locale's translation. IRREVERSIBLE — deleting an entry takes every translation with it and there is no undo. Call FIRST without `confirm` to preview what would be lost, show that to the user, and pass `confirm: true` only once they've agreed to those IDs. This cannot check whether the strings are still referenced in code or Figma — say so when you present the preview.",
684
+ inputSchema: {
685
+ type: "object",
686
+ properties: {
687
+ ids: {
688
+ type: "array",
689
+ items: { type: "string" },
690
+ description: "String IDs to delete (max 25).",
691
+ },
692
+ locale: {
693
+ type: "string",
694
+ description:
695
+ "Clear only this locale's value and keep the entry. Omit to delete the whole entry.",
696
+ },
697
+ confirm: {
698
+ type: "boolean",
699
+ description:
700
+ "Must be true to write. Omitted or false returns a preview and deletes nothing.",
701
+ },
702
+ },
703
+ required: ["ids"],
704
+ },
705
+ handler: async ({ ids, locale, confirm }) => {
706
+ const list = requireBatch(ids, "ids", 25);
707
+ const target = !locale || locale === "base" ? null : String(locale);
708
+ if (target) await assertLocale(target);
709
+
710
+ // Build the loss report from the server BEFORE touching anything — after
711
+ // the delete this information doesn't exist anywhere reachable from here.
712
+ const { byId, notFound } = await lookup(list);
713
+ const found = [];
714
+ const missing = [...notFound];
715
+ for (const id of list) {
716
+ const e = byId.get(id);
717
+ if (!e) continue;
718
+ if (target) {
719
+ const v = e.translations ? e.translations[target] : undefined;
720
+ if (v === undefined) {
721
+ missing.push(`${id} (no ${target} value)`);
722
+ continue;
723
+ }
724
+ found.push({ id, locale: target, losing: v });
725
+ } else {
726
+ found.push({
727
+ id,
728
+ base: e.base,
729
+ losingTranslations: Object.keys(e.translations || {}),
730
+ });
731
+ }
732
+ }
733
+
734
+ const caveat =
735
+ "This server cannot check whether these IDs are still referenced in code or Figma — the editor's delete flow does that check, this one can't. Verify there before deleting anything that might still be live.";
736
+
737
+ if (!confirm)
738
+ return {
739
+ preview: true,
740
+ deleted: [],
741
+ wouldDelete: found,
742
+ notFound: missing,
743
+ scope: target
744
+ ? `Clears the ${target} value only; the entry and its other locales survive.`
745
+ : "Deletes the whole entry: base text, every translation, all plural forms.",
746
+ caveat,
747
+ nextStep:
748
+ "Show this to the user. If they approve, call again with the same ids and `confirm: true`.",
749
+ };
750
+
751
+ if (!found.length)
752
+ return { deleted: [], notFound: missing, note: "Nothing matched; nothing written." };
753
+
754
+ const out = await api("/v2/components", {
755
+ method: "DELETE",
756
+ body: {
757
+ ids: found.map((f) => f.id),
758
+ ...(target ? { variantId: target } : {}),
759
+ },
760
+ });
761
+ const deleted = out.deleted || [];
762
+ return {
763
+ deleted,
764
+ notFound: [...missing, ...(out.missing || [])],
765
+ lost: found.filter((f) => deleted.includes(f.id)),
766
+ caveat,
767
+ };
768
+ },
769
+ },
770
+ ];
771
+
772
+ const TOOLS = READONLY
773
+ ? READ_TOOLS
774
+ : [...READ_TOOLS, ...WRITE_TOOLS, ...(ALLOW_DELETE ? DELETE_TOOLS : [])];
775
+ const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
776
+
777
+ // ── prompts ───────────────────────────────────────────────────────────────────
778
+ //
779
+ // Harvesting a screen is a five-step chain (read the design → triage → confirm
780
+ // → create → report) and no tool description can carry that on its own. An MCP
781
+ // prompt can: clients surface these as slash commands (`/mcp__dittomato__…` in
782
+ // Claude Code), so the whole flow becomes one invocation — and unlike a tool
783
+ // description, the text is only sent when someone invokes it.
784
+ //
785
+ // Note what ISN'T here: anything about reading images or Figma files. The host
786
+ // model already has vision and, if configured, a Figma MCP — this server never
787
+ // sees the design. Its job starts once there's a list of strings.
788
+
789
+ const HARVEST_SCREEN = `Harvest the UI strings from this design into the Dittomato catalog.
790
+
791
+ **1. Read the strings.** Take them from whatever the user gave you:
792
+ - an image or screenshot → read the visible copy off it directly
793
+ - a Figma URL or selection → use your Figma tools (get_design_context / get_metadata) to pull the text layers, which also gives you real layer, frame and page names
794
+ - a code file → the literal strings in it
795
+
796
+ Collect everything that renders as text, including things that look like sample
797
+ data (dates, addresses, numbers). Do NOT pre-filter those out — step 2 decides.
798
+
799
+ **2. Name them yourself.** Fetch the context once:
800
+ - \`get_instructions(kind="naming")\` — the team's ID conventions and the rules for
801
+ what counts as copy vs sample data
802
+ - \`get_naming_context({prefix, texts})\` — which namespaces exist, the
803
+ cross-cutting containers, what's already under the prefix, and per text any
804
+ existing entry with identical text (those are reuse candidates)
805
+ - \`get_glossary({text})\` for anything whose wording you're unsure about
806
+
807
+ Then decide, for each string: **IGNORE** (sample/demo data — dates, addresses,
808
+ measurements — not UI copy), **REUSE** (an existing entry already means this) or
809
+ **CREATE** (a new ID following the conventions).
810
+
811
+ Do this rather than calling \`suggest_ids\`: you can see the design, the code and
812
+ this conversation, and \`suggest_ids\` sends the same job to a second model on the
813
+ server, billed to the team's Anthropic key, with none of that context. Reach for
814
+ it only if you want the answer the web editor and Figma plugin would give, or its
815
+ server-side validation of reuse IDs.
816
+
817
+ Two rules the conventions won't state for you:
818
+ - An ID always has a namespace. Never propose a bare one-word ID — if no feature
819
+ owns the string, that's what the generic containers are for.
820
+ - Check \`namespaceNames\` (the full list) before calling a namespace new;
821
+ \`namespaces\` is ranked and truncated.
822
+
823
+ **3. Show the user a table before writing anything.** Group by verdict:
824
+ - **REUSE** — show the existing ID and its text, so the user can confirm it
825
+ really means the same thing. Identical text is safe; a meaning-level match is a
826
+ judgement call — flag it rather than assuming.
827
+ - **CREATE** — show the proposed ID and one line of reasoning. Call out anything
828
+ opening a new top-level namespace; that's worth a human decision.
829
+ - **IGNORE** — list them briefly so the user can spot a misfire, e.g. real copy
830
+ mistaken for sample data.
831
+
832
+ **4. Create only what the user approves.** Call \`create_strings\` with the
833
+ approved CREATE rows only. Never create an ID for a REUSE row — that's the
834
+ duplicate this whole flow exists to prevent. This writes to the live shared
835
+ catalog and appears in the team's edit history.
836
+
837
+ **5. Report** which IDs were created, which existing IDs to reuse, and anything
838
+ left unresolved. If the user is wiring these into code, give them the IDs in the
839
+ form their codebase uses.`;
840
+
841
+ const TRANSLATE_GAPS = `Find and fill translation gaps in the Dittomato catalog.
842
+
843
+ **1. Scope it.** Ask which locale, and which ID prefix or feature area, unless
844
+ the user already said. Then call \`translation_coverage\` to see how big the gap
845
+ actually is before doing anything.
846
+
847
+ **2. Pull the source text** for the missing IDs with \`get_strings\`. Watch for
848
+ pluralized entries — those need a full CLDR form map, not a single string.
849
+
850
+ **3. Translate against the house rules, yourself.** Fetch
851
+ \`get_instructions(kind="translate")\` once, and \`get_glossary({text})\` per string
852
+ (it filters to the terms that string actually mentions). Follow the glossary
853
+ verbatim — it carries product names that must never be translated or swapped for
854
+ a synonym. That's the difference between a plausible translation and one that
855
+ matches the rest of the product.
856
+
857
+ \`review_copy\` does the same check on the server with a second model call. Use it
858
+ for a second opinion on something you're unsure of, not as the default.
859
+
860
+ **4. Show the user the proposed translations for review**, then write the
861
+ approved ones with \`update_strings\` for that locale. Plural entries take
862
+ \`plurals\` (a full form map), plain ones take \`text\` — never both.
863
+
864
+ Do not machine-translate a whole namespace and push it unreviewed. Small
865
+ reviewed batches.`;
866
+
867
+ const PROMPTS = [
868
+ {
869
+ name: "harvest_screen",
870
+ description:
871
+ "Read the UI strings off a design (image, Figma selection, or code), name them per the team's conventions, catch duplicates, and create the new ones.",
872
+ arguments: [
873
+ {
874
+ name: "prefix",
875
+ description: "Feature namespace hint, e.g. 'checkout' — improves the suggested IDs.",
876
+ required: false,
877
+ },
878
+ ],
879
+ build: ({ prefix } = {}) =>
880
+ HARVEST_SCREEN +
881
+ (prefix
882
+ ? `\n\nThe user says this screen belongs to the **${prefix}** feature area — pass that as \`prefix\`.`
883
+ : ""),
884
+ },
885
+ {
886
+ name: "translate_gaps",
887
+ description:
888
+ "Find untranslated strings for a locale and fill them, checked against the team's glossary and style guide.",
889
+ arguments: [
890
+ { name: "locale", description: "Target locale, e.g. 'de'.", required: false },
891
+ { name: "prefix", description: "Restrict to IDs under this prefix.", required: false },
892
+ ],
893
+ build: ({ locale, prefix } = {}) =>
894
+ TRANSLATE_GAPS +
895
+ (locale || prefix
896
+ ? `\n\nScope for this run:${locale ? ` locale **${locale}**.` : ""}${
897
+ prefix ? ` prefix **${prefix}**.` : ""
898
+ }`
899
+ : ""),
900
+ },
901
+ ];
902
+
903
+ const PROMPTS_BY_NAME = new Map(PROMPTS.map((p) => [p.name, p]));
904
+
905
+ // ── server ────────────────────────────────────────────────────────────────────
906
+
907
+ const server = new Server(
908
+ { name: "dittomato", version: VERSION },
909
+ { capabilities: { tools: {}, prompts: {} } },
910
+ );
911
+
912
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
913
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({
914
+ name,
915
+ description,
916
+ inputSchema,
917
+ })),
918
+ }));
919
+
920
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
921
+ prompts: PROMPTS.map(({ name, description, arguments: args }) => ({
922
+ name,
923
+ description,
924
+ arguments: args,
925
+ })),
926
+ }));
927
+
928
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
929
+ const prompt = PROMPTS_BY_NAME.get(req.params.name);
930
+ if (!prompt) throw new Error(`Unknown prompt: ${req.params.name}`);
931
+ return {
932
+ description: prompt.description,
933
+ messages: [
934
+ {
935
+ role: "user",
936
+ content: { type: "text", text: prompt.build(req.params.arguments || {}) },
937
+ },
938
+ ],
939
+ };
940
+ });
941
+
942
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
943
+ const tool = BY_NAME.get(req.params.name);
944
+ if (!tool)
945
+ return {
946
+ isError: true,
947
+ content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
948
+ };
949
+ try {
950
+ const result = await tool.handler(req.params.arguments || {});
951
+ // Compact, not pretty-printed. Indentation measured at 27% of a
952
+ // search_strings payload, and nothing reads these but a model.
953
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
954
+ } catch (e) {
955
+ // Hand the message back as tool output rather than a protocol error, so the
956
+ // model can read "read-only token" / "no such locale" and correct itself.
957
+ return { isError: true, content: [{ type: "text", text: `${e.message}` }] };
958
+ }
959
+ });
960
+
961
+ // Exported for index.test.js — the handlers carry all the validation worth
962
+ // testing, and reaching them through a spawned stdio process for every case
963
+ // would be slow and hide assertion detail. DELETE_TOOLS separately because it's
964
+ // normally absent from TOOLS.
965
+ module.exports = {
966
+ TOOLS,
967
+ BY_NAME,
968
+ DELETE_TOOLS,
969
+ PROMPTS,
970
+ ID_RE,
971
+ PLURAL_FORMS,
972
+ _resetCache: () => {
973
+ variantsCache = null;
974
+ variantsAt = 0;
975
+ },
976
+ };
977
+
978
+ async function main() {
979
+ if (!HOST || !TOKEN)
980
+ process.stderr.write(
981
+ "dittomato-mcp: DITTO_API_HOST and/or DITTO_API_KEY are unset — every tool " +
982
+ "call will fail until they're configured in the MCP server's env.\n",
983
+ );
984
+ process.stderr.write(
985
+ `dittomato-mcp ${VERSION} ready (${TOOLS.length} tools${
986
+ READONLY ? ", read-only" : ALLOW_DELETE ? ", delete ENABLED" : ""
987
+ })\n`,
988
+ );
989
+ await server.connect(new StdioServerTransport());
990
+ }
991
+
992
+ if (require.main === module)
993
+ main().catch((e) => {
994
+ process.stderr.write(`dittomato-mcp: fatal: ${e.message}\n`);
995
+ process.exit(1);
996
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@vialytics/dittomato-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for a Dittomato backend — search, read and write UI strings from any MCP-capable editor or agent.",
5
+ "bin": {
6
+ "dittomato-mcp": "index.js"
7
+ },
8
+ "main": "index.js",
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "build:bundle": "npx -y esbuild@0.28.2 index.js --bundle --platform=node --target=node18 --format=cjs --minify --legal-comments=none --outfile=build/index.js && cp manifest.json README.md build/",
12
+ "build:pack": "npx -y @anthropic-ai/mcpb@2.1.2 pack build dittomato-mcp.mcpb",
13
+ "build:sign": "npx -y @anthropic-ai/mcpb@2.1.2 sign -c .signing/cert.pem -k .signing/key.pem dittomato-mcp.mcpb",
14
+ "build:mcpb": "npm run build:bundle && npm run build:pack && npm run build:sign",
15
+ "signing:init": "mkdir -p .signing && openssl req -x509 -newkey rsa:2048 -keyout .signing/key.pem -out .signing/cert.pem -days 3650 -nodes -subj \"/CN=vialytics/O=vialytics GmbH/C=DE\" && chmod 600 .signing/key.pem"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/vialytics/dittomato.git",
28
+ "directory": "mcp"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "ditto",
34
+ "dittomato",
35
+ "i18n",
36
+ "translations"
37
+ ],
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.30.0"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }