i18n-tools-mcp 1.0.1 → 1.0.3

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 CHANGED
@@ -1,3 +1,5 @@
1
+ ![i18n-tools-mcp by Trivle — Translation files. Agent-ready.](./i18n-tools-mcp-og.png)
2
+
1
3
  # i18n-tools-mcp
2
4
 
3
5
  MCP server for querying and managing JSON translation files. Designed for AI agents (Claude Code, Cursor, etc.) to read and write i18n, i18next, react-i18next, and next-i18next translations directly through the Model Context Protocol.
@@ -130,6 +132,26 @@ Search translations by value (case-insensitive).
130
132
 
131
133
  Find translation keys that exist in some locales but are missing in others. Takes no parameters.
132
134
 
135
+ ### `batch`
136
+
137
+ Apply multiple mutations in a single call. Each affected locale file is read once, mutated in memory, and written exactly once — regardless of how many ops touch it. Per-file atomic: if any op fails, no files are written.
138
+
139
+ Prefer this over repeated `set`/`add`/`delete`/`rename`/`move` calls for bulk changes. It minimizes file-watcher churn (Next.js dev, Vite HMR) and avoids partial writes.
140
+
141
+ | Parameter | Type | Required | Description |
142
+ |-----------|-----------|----------|----------------------------------------------|
143
+ | `ops` | `BatchOp[]` | yes | Ordered list of operations (see below) |
144
+
145
+ Each op is one of:
146
+
147
+ ```jsonc
148
+ { "op": "set", "locale": "en", "key": "Users.name", "value": "Name" }
149
+ { "op": "add", "key": "Users.email", "translations": { "nl": "E-mail", "en": "Email" } }
150
+ { "op": "delete", "key": "Users.legacy" }
151
+ { "op": "rename", "oldKey": "Users.name", "newKey": "Users.fullName" }
152
+ { "op": "move", "oldKey": "Users.name", "newKey": "Profile.name" }
153
+ ```
154
+
133
155
  ## Development
134
156
 
135
157
  ```bash
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
- import { query, set, add, remove, rename, missing, list, search, move, setIndent } from './translations.js';
5
+ import { query, set, add, remove, rename, missing, list, search, move, batch, setIndent } from './translations.js';
6
6
  const baseDir = process.argv[2];
7
7
  if (!baseDir) {
8
8
  console.error('Usage: i18n-tools-mcp <translations-directory> [base-locale] [indent]');
@@ -148,6 +148,47 @@ server.registerTool('move', {
148
148
  return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
149
149
  }
150
150
  });
151
+ const batchOpSchema = z.discriminatedUnion('op', [
152
+ z.object({
153
+ op: z.literal('set'),
154
+ locale: z.string(),
155
+ key: z.string(),
156
+ value: z.string(),
157
+ }),
158
+ z.object({
159
+ op: z.literal('add'),
160
+ key: z.string(),
161
+ translations: z.record(z.string(), z.string()),
162
+ }),
163
+ z.object({
164
+ op: z.literal('delete'),
165
+ key: z.string(),
166
+ }),
167
+ z.object({
168
+ op: z.literal('rename'),
169
+ oldKey: z.string(),
170
+ newKey: z.string(),
171
+ }),
172
+ z.object({
173
+ op: z.literal('move'),
174
+ oldKey: z.string(),
175
+ newKey: z.string(),
176
+ }),
177
+ ]);
178
+ server.registerTool('batch', {
179
+ description: `Apply multiple translation mutations in a single call. Each affected locale file is written exactly once, regardless of how many ops touch it. Prefer this over repeated set/add/delete/rename/move calls when making bulk changes — it minimizes file-watcher churn (Next.js dev, etc.) and is atomic per-file: if any op fails, no files are written. Always include the base locale "${baseLocale}" in add ops.`,
180
+ inputSchema: {
181
+ ops: z.array(batchOpSchema).describe('Ordered list of operations. Each op is one of: {op:"set", locale, key, value}, {op:"add", key, translations}, {op:"delete", key}, {op:"rename", oldKey, newKey}, {op:"move", oldKey, newKey}.'),
182
+ },
183
+ }, async ({ ops }) => {
184
+ try {
185
+ const result = batch(ops, baseDir);
186
+ return { content: [{ type: 'text', text: result }] };
187
+ }
188
+ catch (error) {
189
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
190
+ }
191
+ });
151
192
  async function main() {
152
193
  const transport = new StdioServerTransport();
153
194
  await server.connect(transport);
@@ -8,3 +8,25 @@ export declare function missing(dir?: string): string;
8
8
  export declare function list(prefix?: string, page?: number, pageSize?: number, dir?: string): string;
9
9
  export declare function search(value: string, page?: number, pageSize?: number, dir?: string): string;
10
10
  export declare function move(oldKey: string, newKey: string, dir?: string): string;
11
+ export type BatchOp = {
12
+ op: 'set';
13
+ locale: string;
14
+ key: string;
15
+ value: string;
16
+ } | {
17
+ op: 'add';
18
+ key: string;
19
+ translations: Record<string, string>;
20
+ } | {
21
+ op: 'delete';
22
+ key: string;
23
+ } | {
24
+ op: 'rename';
25
+ oldKey: string;
26
+ newKey: string;
27
+ } | {
28
+ op: 'move';
29
+ oldKey: string;
30
+ newKey: string;
31
+ };
32
+ export declare function batch(ops: BatchOp[], dir?: string): string;
@@ -341,3 +341,116 @@ export function search(value, page = 1, pageSize = 100, dir) {
341
341
  export function move(oldKey, newKey, dir) {
342
342
  return rename(oldKey, newKey, dir).replace('Renamed', 'Moved');
343
343
  }
344
+ export function batch(ops, dir) {
345
+ const resolvedDir = dir || DEFAULT_DIR;
346
+ const localeFiles = discoverLocaleFiles(resolvedDir);
347
+ const cache = new Map();
348
+ const dirty = new Set();
349
+ const load = (file) => {
350
+ if (!cache.has(file.path)) {
351
+ cache.set(file.path, readJson(file.path));
352
+ }
353
+ return cache.get(file.path);
354
+ };
355
+ const summary = [];
356
+ for (let i = 0; i < ops.length; i++) {
357
+ const op = ops[i];
358
+ try {
359
+ switch (op.op) {
360
+ case 'set': {
361
+ const { namespace, dotKey } = parseKey(op.key);
362
+ if (isNamespaced(localeFiles) && !namespace) {
363
+ throw new Error(`Namespace required in namespace mode. Use "namespace:${op.key}" format.`);
364
+ }
365
+ const file = findLocaleFile(localeFiles, op.locale, namespace);
366
+ if (!file) {
367
+ const target = namespace ? `${op.locale}/${namespace}` : op.locale;
368
+ throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
369
+ }
370
+ setNestedValue(load(file), dotKey, sanitize(op.value));
371
+ dirty.add(file.path);
372
+ summary.push(`set ${op.locale}.${op.key}`);
373
+ break;
374
+ }
375
+ case 'add': {
376
+ const { namespace, dotKey } = parseKey(op.key);
377
+ if (isNamespaced(localeFiles) && !namespace) {
378
+ throw new Error(`Namespace required in namespace mode. Use "namespace:${op.key}" format.`);
379
+ }
380
+ const updated = [];
381
+ const skipped = [];
382
+ for (const [locale, value] of Object.entries(op.translations)) {
383
+ const file = findLocaleFile(localeFiles, locale, namespace);
384
+ if (!file) {
385
+ skipped.push(locale);
386
+ continue;
387
+ }
388
+ setNestedValue(load(file), dotKey, sanitize(value));
389
+ dirty.add(file.path);
390
+ updated.push(locale);
391
+ }
392
+ if (updated.length === 0) {
393
+ throw new Error(`No locales updated for "${op.key}"`);
394
+ }
395
+ const skipNote = skipped.length > 0 ? ` (skipped: ${skipped.join(', ')})` : '';
396
+ summary.push(`add ${op.key} → ${updated.join(', ')}${skipNote}`);
397
+ break;
398
+ }
399
+ case 'delete': {
400
+ const { namespace, dotKey } = parseKey(op.key);
401
+ const filesToSearch = filterFiles(localeFiles, namespace);
402
+ const deleted = [];
403
+ for (const file of filesToSearch) {
404
+ const data = load(file);
405
+ if (deleteNestedValue(data, dotKey)) {
406
+ dirty.add(file.path);
407
+ deleted.push(file.locale);
408
+ }
409
+ }
410
+ summary.push(deleted.length > 0
411
+ ? `delete ${op.key} → ${deleted.join(', ')}`
412
+ : `delete ${op.key} → not found`);
413
+ break;
414
+ }
415
+ case 'rename':
416
+ case 'move': {
417
+ const { namespace: oldNs, dotKey: oldDotKey } = parseKey(op.oldKey);
418
+ const { dotKey: newDotKey } = parseKey(op.newKey);
419
+ const filesToSearch = filterFiles(localeFiles, oldNs);
420
+ const renamed = [];
421
+ for (const file of filesToSearch) {
422
+ const data = load(file);
423
+ const value = getNestedValue(data, oldDotKey);
424
+ if (value === undefined)
425
+ continue;
426
+ deleteNestedValue(data, oldDotKey);
427
+ if (typeof value === 'object' && value !== null) {
428
+ const subKeys = collectKeys(value);
429
+ for (const subKey of subKeys) {
430
+ const subValue = getNestedValue(value, subKey);
431
+ setNestedValue(data, `${newDotKey}.${subKey}`, subValue);
432
+ }
433
+ }
434
+ else {
435
+ setNestedValue(data, newDotKey, value);
436
+ }
437
+ dirty.add(file.path);
438
+ renamed.push(file.locale);
439
+ }
440
+ summary.push(renamed.length > 0
441
+ ? `${op.op} ${op.oldKey} → ${op.newKey} (${renamed.join(', ')})`
442
+ : `${op.op} ${op.oldKey} → not found`);
443
+ break;
444
+ }
445
+ }
446
+ }
447
+ catch (error) {
448
+ throw new Error(`Op #${i + 1} (${op.op}) failed: ${error.message}. No files written.`);
449
+ }
450
+ }
451
+ for (const path of dirty) {
452
+ writeJson(path, cache.get(path));
453
+ }
454
+ const header = `Batch: ${ops.length} op(s), ${dirty.size} file(s) written`;
455
+ return [header, ...summary].join('\n');
456
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-tools-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "MCP server for managing JSON translation files",
5
5
  "type": "module",
6
6
  "bin": {