@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.
- package/README.md +317 -0
- package/index.js +996 -0
- 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
|
+
}
|