i18n-tools-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 ADDED
@@ -0,0 +1,141 @@
1
+ # i18n-tools-mcp
2
+
3
+ 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.
4
+
5
+ Supports flat (`{locale}.json`) and namespaced (`{locale}/{namespace}.json`) directory layouts with dot-notation keys.
6
+
7
+ ## Install
8
+
9
+ Ask your AI agent:
10
+
11
+ > Install the i18n-tools-mcp MCP server. The translations directory is at ./messages and the base locale is nl.
12
+
13
+ Or add it manually to your MCP client config (e.g. `.claude/settings.json`):
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "i18n-tools": {
19
+ "command": "npx",
20
+ "args": ["i18n-tools-mcp", "./messages", "nl"]
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Arguments:
27
+
28
+ 1. **Required**: Path to the directory containing your translation files
29
+ 2. **Optional**: Base locale code (default: `nl`) — used in tool descriptions to guide AI agents
30
+ 3. **Optional**: JSON indentation spaces (default: `4`)
31
+
32
+ ## Directory structures
33
+
34
+ Both flat and namespaced layouts are auto-detected.
35
+
36
+ **Flat** — one JSON file per locale:
37
+
38
+ ```
39
+ messages/
40
+ en.json
41
+ nl.json
42
+ ```
43
+
44
+ **Namespaced** — subdirectory per locale, one JSON file per namespace (i18next-style):
45
+
46
+ ```
47
+ messages/
48
+ en/
49
+ common.json
50
+ auth.json
51
+ nl/
52
+ common.json
53
+ auth.json
54
+ ```
55
+
56
+ In namespace mode, prefix keys with the namespace: `common:Users.name`. Read operations (`query`, `list`, `search`, `missing`) work across all namespaces when no prefix is given. Write operations (`set`, `add`) require a namespace prefix.
57
+
58
+ ## Tools
59
+
60
+ ### `query`
61
+
62
+ Look up a translation key across all locale files. Returns the value for each locale that has the key.
63
+
64
+ | Parameter | Type | Required | Description |
65
+ |-----------|--------|----------|------------------------------------------|
66
+ | `key` | string | yes | Dot-notation key, e.g. `"Users.name"` or `"common:Users.name"` |
67
+
68
+ ### `set`
69
+
70
+ Set a translation value for a single locale. Creates intermediate keys if needed.
71
+
72
+ | Parameter | Type | Required | Description |
73
+ |-----------|--------|----------|------------------------------------------|
74
+ | `locale` | string | yes | Locale code, e.g. `"nl"` or `"en"` |
75
+ | `key` | string | yes | Dot-notation key, e.g. `"Users.name"` or `"common:Users.name"` |
76
+ | `value` | string | yes | The translation value |
77
+
78
+ ### `add`
79
+
80
+ Add a translation key to multiple locales at once.
81
+
82
+ | Parameter | Type | Required | Description |
83
+ |----------------|--------------------------|----------|----------------------------------------------------------|
84
+ | `key` | string | yes | Dot-notation key, e.g. `"Users.name"` or `"common:Users.name"` |
85
+ | `translations` | `Record<string, string>` | yes | Locale-to-value mapping, e.g. `{"nl": "Naam", "en": "Name"}` |
86
+
87
+ ### `delete`
88
+
89
+ Delete a translation key from all locale files.
90
+
91
+ | Parameter | Type | Required | Description |
92
+ |-----------|--------|----------|------------------------------------------|
93
+ | `key` | string | yes | Dot-notation key, e.g. `"Users.name"` or `"common:Users.name"` |
94
+
95
+ ### `rename`
96
+
97
+ Rename a translation key across all locale files. Preserves values.
98
+
99
+ | Parameter | Type | Required | Description |
100
+ |-----------|--------|----------|------------------------------------------------|
101
+ | `oldKey` | string | yes | Current dot-notation key, e.g. `"Users.name"` |
102
+ | `newKey` | string | yes | New dot-notation key, e.g. `"Users.fullName"` |
103
+
104
+ ### `move`
105
+
106
+ Move a translation key to a different path across all locale files.
107
+
108
+ | Parameter | Type | Required | Description |
109
+ |-----------|--------|----------|------------------------------------------------|
110
+ | `oldKey` | string | yes | Current dot-notation key, e.g. `"Users.name"` |
111
+ | `newKey` | string | yes | New dot-notation key, e.g. `"Profile.name"` |
112
+
113
+ ### `list`
114
+
115
+ List all translation keys. Optionally filter by a key prefix.
116
+
117
+ | Parameter | Type | Required | Description |
118
+ |-----------|--------|----------|-------------------------------------------------------|
119
+ | `prefix` | string | no | Key prefix to filter by, e.g. `"Users"` or `"common:"` |
120
+
121
+ ### `search`
122
+
123
+ Search translations by value (case-insensitive).
124
+
125
+ | Parameter | Type | Required | Description |
126
+ |-----------|--------|----------|------------------------------------------|
127
+ | `value` | string | yes | Text to search for in translation values |
128
+
129
+ ### `missing`
130
+
131
+ Find translation keys that exist in some locales but are missing in others. Takes no parameters.
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ pnpm install # Install dependencies
137
+ pnpm dev # Watch mode
138
+ pnpm test # Run tests
139
+ pnpm test:watch # Watch mode tests
140
+ pnpm build # Compile TypeScript
141
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { query, set, add, remove, rename, missing, list, search, move, setIndent } from './translations.js';
6
+ const baseDir = process.argv[2];
7
+ if (!baseDir) {
8
+ console.error('Usage: i18n-tools-mcp <translations-directory> [base-locale] [indent]');
9
+ process.exit(1);
10
+ }
11
+ const baseLocale = process.argv[3] || 'nl';
12
+ const indent = parseInt(process.argv[4] || '4', 10);
13
+ setIndent(indent);
14
+ const server = new McpServer({
15
+ name: 'i18n-tools',
16
+ version: '0.1.0',
17
+ });
18
+ server.registerTool('query', {
19
+ description: 'Look up a translation key across all locale files. Returns the value for each locale that has the key.',
20
+ inputSchema: {
21
+ key: z.string().describe('Dot-notation translation key, e.g. "Users.name"'),
22
+ },
23
+ }, async ({ key }) => {
24
+ try {
25
+ const result = query(key, baseDir);
26
+ return { content: [{ type: 'text', text: result }] };
27
+ }
28
+ catch (error) {
29
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
30
+ }
31
+ });
32
+ server.registerTool('set', {
33
+ description: 'Set a translation value for a single locale. Creates intermediate keys if needed.',
34
+ inputSchema: {
35
+ locale: z.string().describe(`Locale code, e.g. "${baseLocale}" or "en"`),
36
+ key: z.string().describe('Dot-notation translation key, e.g. "Users.name"'),
37
+ value: z.string().describe('The translation value to set'),
38
+ },
39
+ }, async ({ locale, key, value }) => {
40
+ try {
41
+ const result = set(locale, key, value, baseDir);
42
+ return { content: [{ type: 'text', text: result }] };
43
+ }
44
+ catch (error) {
45
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
46
+ }
47
+ });
48
+ server.registerTool('add', {
49
+ description: `Add a translation key to multiple locales at once. Always include at least the base locale "${baseLocale}". Other locales are optional and will be filled by the AI translation pipeline.`,
50
+ inputSchema: {
51
+ key: z.string().describe('Dot-notation translation key, e.g. "Users.name"'),
52
+ translations: z.record(z.string(), z.string()).describe(`Object mapping locale codes to translation values. Always include "${baseLocale}" as the base locale. Example: {"${baseLocale}": "Naam", "en": "Name"}`),
53
+ },
54
+ }, async ({ key, translations }) => {
55
+ try {
56
+ const result = add(key, translations, baseDir);
57
+ return { content: [{ type: 'text', text: result }] };
58
+ }
59
+ catch (error) {
60
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
61
+ }
62
+ });
63
+ server.registerTool('delete', {
64
+ description: 'Delete a translation key from all locale files.',
65
+ inputSchema: {
66
+ key: z.string().describe('Dot-notation translation key, e.g. "Users.name"'),
67
+ },
68
+ }, async ({ key }) => {
69
+ try {
70
+ const result = remove(key, baseDir);
71
+ return { content: [{ type: 'text', text: result }] };
72
+ }
73
+ catch (error) {
74
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
75
+ }
76
+ });
77
+ server.registerTool('rename', {
78
+ description: 'Rename a translation key across all locale files. Preserves values.',
79
+ inputSchema: {
80
+ oldKey: z.string().describe('Current dot-notation key, e.g. "Users.name"'),
81
+ newKey: z.string().describe('New dot-notation key, e.g. "Users.fullName"'),
82
+ },
83
+ }, async ({ oldKey, newKey }) => {
84
+ try {
85
+ const result = rename(oldKey, newKey, baseDir);
86
+ return { content: [{ type: 'text', text: result }] };
87
+ }
88
+ catch (error) {
89
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
90
+ }
91
+ });
92
+ server.registerTool('missing', {
93
+ description: 'Find translation keys that exist in some locales but are missing in others.',
94
+ inputSchema: {},
95
+ }, async () => {
96
+ try {
97
+ const result = missing(baseDir);
98
+ return { content: [{ type: 'text', text: result }] };
99
+ }
100
+ catch (error) {
101
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
102
+ }
103
+ });
104
+ server.registerTool('list', {
105
+ description: 'List all translation keys. Optionally filter by a key prefix. Results are paginated (100 keys per page).',
106
+ inputSchema: {
107
+ prefix: z.string().optional().describe('Key prefix to filter by, e.g. "Users" to list all keys under Users'),
108
+ page: z.number().optional().describe('Page number (default: 1)'),
109
+ pageSize: z.number().optional().describe('Number of keys per page (default: 100)'),
110
+ },
111
+ }, async ({ prefix, page, pageSize }) => {
112
+ try {
113
+ const result = list(prefix, page ?? 1, pageSize ?? 100, baseDir);
114
+ return { content: [{ type: 'text', text: result }] };
115
+ }
116
+ catch (error) {
117
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
118
+ }
119
+ });
120
+ server.registerTool('search', {
121
+ description: 'Search translations by value. Finds all keys where the translation contains the search term (case-insensitive). Results are paginated (100 results per page).',
122
+ inputSchema: {
123
+ value: z.string().describe('Text to search for in translation values'),
124
+ page: z.number().optional().describe('Page number (default: 1)'),
125
+ pageSize: z.number().optional().describe('Number of results per page (default: 100)'),
126
+ },
127
+ }, async ({ value, page, pageSize }) => {
128
+ try {
129
+ const result = search(value, page ?? 1, pageSize ?? 100, baseDir);
130
+ return { content: [{ type: 'text', text: result }] };
131
+ }
132
+ catch (error) {
133
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
134
+ }
135
+ });
136
+ server.registerTool('move', {
137
+ description: 'Move a translation key to a different path across all locale files. Same as rename.',
138
+ inputSchema: {
139
+ oldKey: z.string().describe('Current dot-notation key, e.g. "Users.name"'),
140
+ newKey: z.string().describe('New dot-notation key, e.g. "Profile.name"'),
141
+ },
142
+ }, async ({ oldKey, newKey }) => {
143
+ try {
144
+ const result = move(oldKey, newKey, baseDir);
145
+ return { content: [{ type: 'text', text: result }] };
146
+ }
147
+ catch (error) {
148
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
149
+ }
150
+ });
151
+ async function main() {
152
+ const transport = new StdioServerTransport();
153
+ await server.connect(transport);
154
+ }
155
+ main().catch((error) => {
156
+ console.error('Fatal error:', error);
157
+ process.exit(1);
158
+ });
@@ -0,0 +1,10 @@
1
+ export declare function setIndent(spaces: number): void;
2
+ export declare function query(key: string, dir?: string): string;
3
+ export declare function set(locale: string, key: string, value: string, dir?: string): string;
4
+ export declare function remove(key: string, dir?: string): string;
5
+ export declare function add(key: string, translations: Record<string, string>, dir?: string): string;
6
+ export declare function rename(oldKey: string, newKey: string, dir?: string): string;
7
+ export declare function missing(dir?: string): string;
8
+ export declare function list(prefix?: string, page?: number, pageSize?: number, dir?: string): string;
9
+ export declare function search(value: string, page?: number, pageSize?: number, dir?: string): string;
10
+ export declare function move(oldKey: string, newKey: string, dir?: string): string;
@@ -0,0 +1,343 @@
1
+ import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
2
+ import { join, basename, resolve } from 'node:path';
3
+ const DEFAULT_DIR = 'messages';
4
+ const NS_SEPARATOR = ':';
5
+ let jsonIndent = 4;
6
+ function sanitize(value) {
7
+ return value
8
+ .replace(/[\u201C\u201D\u201E\u201F\u2033\u2036]/g, '"')
9
+ .replace(/[\u2018\u2019\u201A\u201B\u2032\u2035]/g, "'");
10
+ }
11
+ function discoverLocaleFiles(dir) {
12
+ const resolvedDir = resolve(dir);
13
+ const entries = readdirSync(resolvedDir, { withFileTypes: true });
14
+ const subdirs = entries.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
15
+ if (subdirs.length > 0) {
16
+ const files = [];
17
+ for (const subdir of subdirs) {
18
+ const localePath = join(resolvedDir, subdir.name);
19
+ const jsonFiles = readdirSync(localePath).filter((f) => f.endsWith('.json')).sort();
20
+ for (const f of jsonFiles) {
21
+ files.push({
22
+ locale: subdir.name,
23
+ namespace: basename(f, '.json'),
24
+ path: join(localePath, f),
25
+ });
26
+ }
27
+ }
28
+ return files;
29
+ }
30
+ const jsonFiles = entries
31
+ .filter((e) => e.isFile() && e.name.endsWith('.json'))
32
+ .sort((a, b) => a.name.localeCompare(b.name));
33
+ return jsonFiles.map((f) => ({
34
+ locale: basename(f.name, '.json'),
35
+ path: join(resolvedDir, f.name),
36
+ }));
37
+ }
38
+ function parseKey(key) {
39
+ const colonIndex = key.indexOf(NS_SEPARATOR);
40
+ if (colonIndex > 0) {
41
+ return { namespace: key.substring(0, colonIndex), dotKey: key.substring(colonIndex + 1) };
42
+ }
43
+ return { dotKey: key };
44
+ }
45
+ function isNamespaced(files) {
46
+ return files.some((f) => f.namespace !== undefined);
47
+ }
48
+ function fullKey(file, dotKey) {
49
+ if (file.namespace) {
50
+ return `${file.namespace}${NS_SEPARATOR}${dotKey}`;
51
+ }
52
+ return dotKey;
53
+ }
54
+ function readJson(filePath) {
55
+ const raw = readFileSync(filePath, 'utf8');
56
+ return JSON.parse(raw);
57
+ }
58
+ function writeJson(filePath, data) {
59
+ const json = JSON.stringify(data, null, jsonIndent) + '\n';
60
+ writeFileSync(filePath, json, 'utf8');
61
+ }
62
+ export function setIndent(spaces) {
63
+ jsonIndent = spaces;
64
+ }
65
+ function getNestedValue(obj, key) {
66
+ const parts = key.split('.');
67
+ let current = obj;
68
+ for (const part of parts) {
69
+ if (current == null || typeof current !== 'object') {
70
+ return undefined;
71
+ }
72
+ current = current[part];
73
+ }
74
+ return current;
75
+ }
76
+ function deleteNestedValue(obj, key) {
77
+ const parts = key.split('.');
78
+ let current = obj;
79
+ for (let i = 0; i < parts.length - 1; i++) {
80
+ const part = parts[i];
81
+ if (current[part] == null || typeof current[part] !== 'object') {
82
+ return false;
83
+ }
84
+ current = current[part];
85
+ }
86
+ const lastPart = parts[parts.length - 1];
87
+ if (!(lastPart in current)) {
88
+ return false;
89
+ }
90
+ delete current[lastPart];
91
+ return true;
92
+ }
93
+ function setNestedValue(obj, key, value) {
94
+ const parts = key.split('.');
95
+ let current = obj;
96
+ for (let i = 0; i < parts.length - 1; i++) {
97
+ const part = parts[i];
98
+ if (current[part] == null || typeof current[part] !== 'object') {
99
+ current[part] = {};
100
+ }
101
+ current = current[part];
102
+ }
103
+ current[parts[parts.length - 1]] = value;
104
+ }
105
+ function collectKeys(obj, prefix = '') {
106
+ const keys = [];
107
+ for (const [k, v] of Object.entries(obj)) {
108
+ const fk = prefix ? `${prefix}.${k}` : k;
109
+ if (v != null && typeof v === 'object' && !Array.isArray(v)) {
110
+ keys.push(...collectKeys(v, fk));
111
+ }
112
+ else {
113
+ keys.push(fk);
114
+ }
115
+ }
116
+ return keys;
117
+ }
118
+ function filterFiles(files, namespace) {
119
+ if (!namespace) {
120
+ return files;
121
+ }
122
+ return files.filter((f) => f.namespace === namespace);
123
+ }
124
+ function findLocaleFile(files, locale, namespace) {
125
+ if (namespace) {
126
+ return files.find((f) => f.locale === locale && f.namespace === namespace);
127
+ }
128
+ return files.find((f) => f.locale === locale);
129
+ }
130
+ export function query(key, dir) {
131
+ const resolvedDir = dir || DEFAULT_DIR;
132
+ const localeFiles = discoverLocaleFiles(resolvedDir);
133
+ const { namespace, dotKey } = parseKey(key);
134
+ const filesToSearch = filterFiles(localeFiles, namespace);
135
+ const results = [];
136
+ for (const file of filesToSearch) {
137
+ const data = readJson(file.path);
138
+ const value = getNestedValue(data, dotKey);
139
+ if (value !== undefined) {
140
+ const label = !namespace && file.namespace
141
+ ? `${file.locale} (${file.namespace})`
142
+ : file.locale;
143
+ if (typeof value === 'object') {
144
+ results.push(`${label}: ${JSON.stringify(value)}`);
145
+ }
146
+ else {
147
+ results.push(`${label}: ${value}`);
148
+ }
149
+ }
150
+ }
151
+ if (results.length === 0) {
152
+ return `Key "${key}" not found in any locale.`;
153
+ }
154
+ return results.join('\n');
155
+ }
156
+ export function set(locale, key, value, dir) {
157
+ const resolvedDir = dir || DEFAULT_DIR;
158
+ const localeFiles = discoverLocaleFiles(resolvedDir);
159
+ const { namespace, dotKey } = parseKey(key);
160
+ if (isNamespaced(localeFiles) && !namespace) {
161
+ throw new Error(`Namespace required in namespace mode. Use "namespace:${key}" format.`);
162
+ }
163
+ const localeFile = findLocaleFile(localeFiles, locale, namespace);
164
+ if (!localeFile) {
165
+ const target = namespace ? `${locale}/${namespace}` : locale;
166
+ throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
167
+ }
168
+ const data = readJson(localeFile.path);
169
+ setNestedValue(data, dotKey, sanitize(value));
170
+ writeJson(localeFile.path, data);
171
+ return `Set ${locale}.${key} = "${value}"`;
172
+ }
173
+ export function remove(key, dir) {
174
+ const resolvedDir = dir || DEFAULT_DIR;
175
+ const localeFiles = discoverLocaleFiles(resolvedDir);
176
+ const { namespace, dotKey } = parseKey(key);
177
+ const filesToSearch = filterFiles(localeFiles, namespace);
178
+ const deleted = [];
179
+ for (const file of filesToSearch) {
180
+ const data = readJson(file.path);
181
+ if (deleteNestedValue(data, dotKey)) {
182
+ writeJson(file.path, data);
183
+ const label = !namespace && file.namespace
184
+ ? `${file.locale} (${file.namespace})`
185
+ : file.locale;
186
+ deleted.push(label);
187
+ }
188
+ }
189
+ if (deleted.length === 0) {
190
+ return `Key "${key}" not found in any locale.`;
191
+ }
192
+ return `Deleted "${key}" from ${deleted.length} locale(s): ${deleted.join(', ')}`;
193
+ }
194
+ export function add(key, translations, dir) {
195
+ const resolvedDir = dir || DEFAULT_DIR;
196
+ const localeFiles = discoverLocaleFiles(resolvedDir);
197
+ const { namespace, dotKey } = parseKey(key);
198
+ if (isNamespaced(localeFiles) && !namespace) {
199
+ throw new Error(`Namespace required in namespace mode. Use "namespace:${key}" format.`);
200
+ }
201
+ const updated = [];
202
+ const warnings = [];
203
+ for (const [locale, value] of Object.entries(translations)) {
204
+ const localeFile = findLocaleFile(localeFiles, locale, namespace);
205
+ if (!localeFile) {
206
+ const target = namespace ? `${locale}/${namespace}` : locale;
207
+ warnings.push(`Warning: locale "${target}" not found in ${resolvedDir}, skipping`);
208
+ continue;
209
+ }
210
+ const data = readJson(localeFile.path);
211
+ setNestedValue(data, dotKey, sanitize(value));
212
+ writeJson(localeFile.path, data);
213
+ updated.push(locale);
214
+ }
215
+ if (updated.length === 0) {
216
+ throw new Error('No locales were updated');
217
+ }
218
+ const lines = [];
219
+ if (warnings.length > 0) {
220
+ lines.push(...warnings);
221
+ }
222
+ lines.push(`Added "${key}" to ${updated.length} locale(s): ${updated.join(', ')}`);
223
+ return lines.join('\n');
224
+ }
225
+ export function rename(oldKey, newKey, dir) {
226
+ const resolvedDir = dir || DEFAULT_DIR;
227
+ const localeFiles = discoverLocaleFiles(resolvedDir);
228
+ const { namespace: oldNs, dotKey: oldDotKey } = parseKey(oldKey);
229
+ const { dotKey: newDotKey } = parseKey(newKey);
230
+ const filesToSearch = filterFiles(localeFiles, oldNs);
231
+ const renamed = [];
232
+ for (const file of filesToSearch) {
233
+ const data = readJson(file.path);
234
+ const value = getNestedValue(data, oldDotKey);
235
+ if (value === undefined) {
236
+ continue;
237
+ }
238
+ deleteNestedValue(data, oldDotKey);
239
+ if (typeof value === 'object' && value !== null) {
240
+ const subKeys = collectKeys(value);
241
+ for (const subKey of subKeys) {
242
+ const subValue = getNestedValue(value, subKey);
243
+ setNestedValue(data, `${newDotKey}.${subKey}`, subValue);
244
+ }
245
+ }
246
+ else {
247
+ setNestedValue(data, newDotKey, value);
248
+ }
249
+ writeJson(file.path, data);
250
+ const label = !oldNs && file.namespace
251
+ ? `${file.locale} (${file.namespace})`
252
+ : file.locale;
253
+ renamed.push(label);
254
+ }
255
+ if (renamed.length === 0) {
256
+ return `Key "${oldKey}" not found in any locale.`;
257
+ }
258
+ return `Renamed "${oldKey}" to "${newKey}" in ${renamed.length} locale(s): ${renamed.join(', ')}`;
259
+ }
260
+ export function missing(dir) {
261
+ const resolvedDir = dir || DEFAULT_DIR;
262
+ const localeFiles = discoverLocaleFiles(resolvedDir);
263
+ const allKeys = new Set();
264
+ const localeKeys = new Map();
265
+ for (const file of localeFiles) {
266
+ const data = readJson(file.path);
267
+ const keys = collectKeys(data).map((k) => fullKey(file, k));
268
+ if (!localeKeys.has(file.locale)) {
269
+ localeKeys.set(file.locale, new Set());
270
+ }
271
+ const existing = localeKeys.get(file.locale);
272
+ keys.forEach((k) => {
273
+ allKeys.add(k);
274
+ existing.add(k);
275
+ });
276
+ }
277
+ const results = [];
278
+ for (const [locale, keys] of localeKeys) {
279
+ const missingKeys = [...allKeys].filter((k) => !keys.has(k)).sort();
280
+ if (missingKeys.length > 0) {
281
+ results.push(`${locale}: ${missingKeys.join(', ')}`);
282
+ }
283
+ }
284
+ if (results.length === 0) {
285
+ return 'All locales have all keys.';
286
+ }
287
+ return results.join('\n');
288
+ }
289
+ export function list(prefix, page = 1, pageSize = 100, dir) {
290
+ const resolvedDir = dir || DEFAULT_DIR;
291
+ const localeFiles = discoverLocaleFiles(resolvedDir);
292
+ const allKeys = new Set();
293
+ for (const file of localeFiles) {
294
+ const data = readJson(file.path);
295
+ collectKeys(data).forEach((k) => allKeys.add(fullKey(file, k)));
296
+ }
297
+ let keys = [...allKeys].sort();
298
+ if (prefix) {
299
+ keys = keys.filter((k) => k.startsWith(prefix));
300
+ }
301
+ if (keys.length === 0) {
302
+ return prefix ? `No keys found with prefix "${prefix}".` : 'No keys found.';
303
+ }
304
+ const totalPages = Math.ceil(keys.length / pageSize);
305
+ const start = (page - 1) * pageSize;
306
+ const pageKeys = keys.slice(start, start + pageSize);
307
+ if (pageKeys.length === 0) {
308
+ return `Page ${page} is out of range. Total pages: ${totalPages}`;
309
+ }
310
+ const header = `Page ${page}/${totalPages} (${keys.length} keys total)`;
311
+ return `${header}\n${pageKeys.join('\n')}`;
312
+ }
313
+ export function search(value, page = 1, pageSize = 100, dir) {
314
+ const resolvedDir = dir || DEFAULT_DIR;
315
+ const localeFiles = discoverLocaleFiles(resolvedDir);
316
+ const results = [];
317
+ const lowerValue = value.toLowerCase();
318
+ for (const file of localeFiles) {
319
+ const data = readJson(file.path);
320
+ const keys = collectKeys(data);
321
+ for (const key of keys) {
322
+ const v = getNestedValue(data, key);
323
+ if (typeof v === 'string' && v.toLowerCase().includes(lowerValue)) {
324
+ const fk = fullKey(file, key);
325
+ results.push(`${file.locale}.${fk} = "${v}"`);
326
+ }
327
+ }
328
+ }
329
+ if (results.length === 0) {
330
+ return `No translations found containing "${value}".`;
331
+ }
332
+ const totalPages = Math.ceil(results.length / pageSize);
333
+ const start = (page - 1) * pageSize;
334
+ const pageResults = results.slice(start, start + pageSize);
335
+ if (pageResults.length === 0) {
336
+ return `Page ${page} is out of range. Total pages: ${totalPages}`;
337
+ }
338
+ const header = `Page ${page}/${totalPages} (${results.length} results total)`;
339
+ return `${header}\n${pageResults.join('\n')}`;
340
+ }
341
+ export function move(oldKey, newKey, dir) {
342
+ return rename(oldKey, newKey, dir).replace('Renamed', 'Moved');
343
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "i18n-tools-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for managing JSON translation files",
5
+ "type": "module",
6
+ "bin": {
7
+ "i18n-tools-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "!dist/**/*.test.*"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "prepublishOnly": "pnpm build"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "i18n",
23
+ "translations",
24
+ "internationalization",
25
+ "i18next",
26
+ "react-i18next",
27
+ "next-i18next",
28
+ "json",
29
+ "locale",
30
+ "model-context-protocol"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/Trivle/i18n-tools-mcp"
35
+ },
36
+ "packageManager": "pnpm@10.30.3",
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.12.1",
39
+ "zod": "^4.3.6"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "typescript": "^5.7.0",
44
+ "vitest": "^4.0.18"
45
+ }
46
+ }