localization-mcp-server 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/.env.example +9 -0
- package/AGENT_GUIDE.md +556 -0
- package/AUDIT_REPORT.md +244 -0
- package/PROJECT_OVERVIEW.md +140 -0
- package/dist/api-client.d.ts +11 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +67 -0
- package/dist/api-client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-aliases.d.ts +46 -0
- package/dist/locale-aliases.d.ts.map +1 -0
- package/dist/locale-aliases.js +71 -0
- package/dist/locale-aliases.js.map +1 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +19 -0
- package/dist/logger.js.map +1 -0
- package/dist/permissions.d.ts +127 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +75 -0
- package/dist/permissions.js.map +1 -0
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +129 -0
- package/dist/prompts.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +25 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/diff.d.ts +3 -0
- package/dist/tools/diff.d.ts.map +1 -0
- package/dist/tools/diff.js +166 -0
- package/dist/tools/diff.js.map +1 -0
- package/dist/tools/environment.d.ts +3 -0
- package/dist/tools/environment.d.ts.map +1 -0
- package/dist/tools/environment.js +113 -0
- package/dist/tools/environment.js.map +1 -0
- package/dist/tools/production.d.ts +3 -0
- package/dist/tools/production.d.ts.map +1 -0
- package/dist/tools/production.js +145 -0
- package/dist/tools/production.js.map +1 -0
- package/dist/tools/project-management.d.ts +3 -0
- package/dist/tools/project-management.d.ts.map +1 -0
- package/dist/tools/project-management.js +416 -0
- package/dist/tools/project-management.js.map +1 -0
- package/dist/tools/sandbox-writes.d.ts +3 -0
- package/dist/tools/sandbox-writes.d.ts.map +1 -0
- package/dist/tools/sandbox-writes.js +260 -0
- package/dist/tools/sandbox-writes.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +50 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/translations.d.ts +3 -0
- package/dist/tools/translations.d.ts.map +1 -0
- package/dist/tools/translations.js +135 -0
- package/dist/tools/translations.js.map +1 -0
- package/migrate-expenses.cjs +120 -0
- package/package.json +26 -0
- package/src/api-client.ts +68 -0
- package/src/index.ts +29 -0
- package/src/logger.ts +31 -0
- package/src/permissions.ts +89 -0
- package/src/prompts.ts +159 -0
- package/src/server.ts +27 -0
- package/src/tools/diff.ts +225 -0
- package/src/tools/environment.ts +175 -0
- package/src/tools/production.ts +196 -0
- package/src/tools/project-management.ts +517 -0
- package/src/tools/sandbox-writes.ts +321 -0
- package/src/tools/snapshots.ts +68 -0
- package/src/tools/translations.ts +167 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { apiGet, apiPost, ApiError } from "../api-client.js";
|
|
4
|
+
import { logWrite } from "../logger.js";
|
|
5
|
+
|
|
6
|
+
interface NamespaceCreated {
|
|
7
|
+
id: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface LocaleCreated {
|
|
12
|
+
id: string;
|
|
13
|
+
code: string;
|
|
14
|
+
isDefault: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TranslationEntry {
|
|
18
|
+
key: string;
|
|
19
|
+
values: Record<string, string>;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface EntriesPage {
|
|
24
|
+
data: TranslationEntry[];
|
|
25
|
+
meta: { total: number; page: number; limit: number; totalPages: number };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registerProjectManagementTools(server: McpServer): void {
|
|
29
|
+
// ─── create_namespace ──────────────────────────────────────────────────────
|
|
30
|
+
server.tool(
|
|
31
|
+
"create_namespace",
|
|
32
|
+
[
|
|
33
|
+
"Create a new namespace in a project.",
|
|
34
|
+
"IMPORTANT: Call get_project_details first and check existing namespaces before using this tool.",
|
|
35
|
+
"Reuse an existing namespace whenever context makes the target clear.",
|
|
36
|
+
"Do NOT create a new namespace just because the request did not name one explicitly.",
|
|
37
|
+
"Creating a namespace is an architectural decision — it must be justified.",
|
|
38
|
+
"You MUST provide a non-empty reason explaining why no existing namespace fits.",
|
|
39
|
+
"If you cannot state a clear reason, do not create — ask the user instead.",
|
|
40
|
+
"Namespace slugs must be lowercase alphanumeric with dashes.",
|
|
41
|
+
].join(" "),
|
|
42
|
+
{
|
|
43
|
+
projectSlug: z.string().describe("Project slug"),
|
|
44
|
+
namespace: z
|
|
45
|
+
.string()
|
|
46
|
+
.regex(/^[a-z0-9-]+$/, "Namespace slug must be lowercase alphanumeric with dashes")
|
|
47
|
+
.describe("Namespace slug (e.g. 'expenses', 'mobile-v2')"),
|
|
48
|
+
reason: z
|
|
49
|
+
.string()
|
|
50
|
+
.min(10, "Reason must be at least 10 characters — explain why no existing namespace fits")
|
|
51
|
+
.describe(
|
|
52
|
+
"Why a new namespace is needed. Mention which existing namespaces you checked and why they do not fit. " +
|
|
53
|
+
"Example: 'Project has no namespaces yet' or 'Existing: common, backoffice. This feature (payments) is a separate domain with its own release cadence.'",
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
async ({ projectSlug, namespace, reason }) => {
|
|
57
|
+
// Soft guard: fetch existing namespaces and warn if any exist without a strong reason.
|
|
58
|
+
let existingNamespaces: string[] = [];
|
|
59
|
+
try {
|
|
60
|
+
const project = await apiGet<{ namespaces: string[] }>(`/translations/projects/${projectSlug}`);
|
|
61
|
+
existingNamespaces = project.namespaces;
|
|
62
|
+
} catch {
|
|
63
|
+
// If we can't fetch, proceed — backend will enforce access control.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const created = await apiPost<NamespaceCreated>(
|
|
68
|
+
`/translations/projects/${projectSlug}/namespaces`,
|
|
69
|
+
{ slug: namespace },
|
|
70
|
+
);
|
|
71
|
+
logWrite("create_namespace", { projectSlug, namespace, reason, existingNamespaces }, created);
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: "text" as const,
|
|
76
|
+
text: [
|
|
77
|
+
`Created namespace: ${projectSlug}/${namespace}`,
|
|
78
|
+
existingNamespaces.length > 0
|
|
79
|
+
? `Existing namespaces at time of creation: ${existingNamespaces.join(", ")}`
|
|
80
|
+
: `This is the first namespace in the project.`,
|
|
81
|
+
`Reason provided: ${reason}`,
|
|
82
|
+
``,
|
|
83
|
+
`The namespace is empty. Use set_translation to add keys, or bulk_import to load from a JSON map.`,
|
|
84
|
+
`Remember to init_sandbox after creating the namespace if you plan to use sandbox workflow.`,
|
|
85
|
+
].join("\n"),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return errorContent(error);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// ─── create_locale ─────────────────────────────────────────────────────────
|
|
96
|
+
server.tool(
|
|
97
|
+
"create_locale",
|
|
98
|
+
[
|
|
99
|
+
"Add a locale to a project.",
|
|
100
|
+
"Use full BCP 47 codes: 'nb-NO', 'da-DK', 'sv', 'en', 'uk'.",
|
|
101
|
+
"Do NOT use short codes like 'no' or 'da' — they are not stored on the server.",
|
|
102
|
+
"After adding a locale, use get_namespace_coverage to see fill gaps, then bulk_set_locale to fill them.",
|
|
103
|
+
].join(" "),
|
|
104
|
+
{
|
|
105
|
+
projectSlug: z.string().describe("Project slug"),
|
|
106
|
+
code: z
|
|
107
|
+
.string()
|
|
108
|
+
.describe("BCP 47 locale code (e.g. 'nb-NO', 'da-DK', 'sv', 'uk')"),
|
|
109
|
+
isDefault: z
|
|
110
|
+
.boolean()
|
|
111
|
+
.default(false)
|
|
112
|
+
.describe("Whether this is the default locale for the project"),
|
|
113
|
+
},
|
|
114
|
+
async ({ projectSlug, code, isDefault }) => {
|
|
115
|
+
try {
|
|
116
|
+
// Fetch existing namespaces to guide the next step.
|
|
117
|
+
let namespaces: string[] = [];
|
|
118
|
+
try {
|
|
119
|
+
const project = await apiGet<{ namespaces: string[] }>(`/translations/projects/${projectSlug}`);
|
|
120
|
+
namespaces = project.namespaces;
|
|
121
|
+
} catch {
|
|
122
|
+
// Non-fatal — just skip the hint.
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const created = await apiPost<LocaleCreated>(
|
|
126
|
+
`/translations/projects/${projectSlug}/locales`,
|
|
127
|
+
{ code, isDefault },
|
|
128
|
+
);
|
|
129
|
+
logWrite("create_locale", { projectSlug, code, isDefault }, created);
|
|
130
|
+
|
|
131
|
+
const nextSteps =
|
|
132
|
+
namespaces.length > 0
|
|
133
|
+
? [
|
|
134
|
+
``,
|
|
135
|
+
`Next steps to fill translations for "${code}":`,
|
|
136
|
+
`1. get_namespace_coverage for each namespace to see fill gaps`,
|
|
137
|
+
` Namespaces: ${namespaces.join(", ")}`,
|
|
138
|
+
`2. list_translations with missingLocale="${code}" to find keys needing translation`,
|
|
139
|
+
`3. bulk_set_locale to fill many keys at once for "${code}"`,
|
|
140
|
+
` Or: set_translation for individual keys`,
|
|
141
|
+
]
|
|
142
|
+
: [``, `No namespaces exist yet — create a namespace first, then add translations.`];
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
content: [
|
|
146
|
+
{
|
|
147
|
+
type: "text" as const,
|
|
148
|
+
text: [
|
|
149
|
+
`Added locale: ${code}${isDefault ? " (default)" : ""} to project ${projectSlug}`,
|
|
150
|
+
...nextSteps,
|
|
151
|
+
].join("\n"),
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return errorContent(error);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// ─── export_namespace ──────────────────────────────────────────────────────
|
|
162
|
+
server.tool(
|
|
163
|
+
"export_namespace",
|
|
164
|
+
"Export all translation keys for a namespace as a flat JSON map, per locale. Use this to see the full content of a namespace, compare before/after migration, or get the data needed for bulk_import.",
|
|
165
|
+
{
|
|
166
|
+
projectSlug: z.string().describe("Project slug"),
|
|
167
|
+
namespace: z.string().describe("Namespace slug"),
|
|
168
|
+
env: z
|
|
169
|
+
.enum(["sandbox", "production"])
|
|
170
|
+
.default("production")
|
|
171
|
+
.describe("Which environment to export from (default: production)"),
|
|
172
|
+
locale: z
|
|
173
|
+
.string()
|
|
174
|
+
.optional()
|
|
175
|
+
.describe("Export only this locale. If omitted, exports all locales."),
|
|
176
|
+
},
|
|
177
|
+
async ({ projectSlug, namespace, env, locale }) => {
|
|
178
|
+
try {
|
|
179
|
+
// Fetch project locales
|
|
180
|
+
const project = await apiGet<{ locales: { code: string; isDefault: boolean }[]; namespaces: string[] }>(
|
|
181
|
+
`/translations/projects/${projectSlug}`,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const targetLocales = locale ? [locale] : project.locales.map((l) => l.code);
|
|
185
|
+
const basePath =
|
|
186
|
+
env === "sandbox"
|
|
187
|
+
? `/translations/projects/${projectSlug}/sandbox/namespaces/${namespace}/entries`
|
|
188
|
+
: `/translations/projects/${projectSlug}/namespaces/${namespace}/entries`;
|
|
189
|
+
|
|
190
|
+
// Fetch all pages
|
|
191
|
+
const allEntries: TranslationEntry[] = [];
|
|
192
|
+
let page = 1;
|
|
193
|
+
while (true) {
|
|
194
|
+
const data = await apiGet<EntriesPage>(basePath, { page, limit: 100 });
|
|
195
|
+
allEntries.push(...data.data);
|
|
196
|
+
if (page >= data.meta.totalPages) break;
|
|
197
|
+
page++;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (allEntries.length === 0) {
|
|
201
|
+
return {
|
|
202
|
+
content: [
|
|
203
|
+
{
|
|
204
|
+
type: "text" as const,
|
|
205
|
+
text: `Namespace ${projectSlug}/${namespace} [${env}] is empty.`,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Build per-locale maps
|
|
212
|
+
const result: Record<string, Record<string, string>> = {};
|
|
213
|
+
for (const loc of targetLocales) {
|
|
214
|
+
result[loc] = {};
|
|
215
|
+
for (const entry of allEntries) {
|
|
216
|
+
if (entry.values[loc] !== undefined) {
|
|
217
|
+
result[loc][entry.key] = entry.values[loc];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lines = [
|
|
223
|
+
`Export: ${projectSlug}/${namespace} [${env}]`,
|
|
224
|
+
`Keys: ${allEntries.length}`,
|
|
225
|
+
`Locales: ${targetLocales.join(", ")}`,
|
|
226
|
+
``,
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
for (const [loc, map] of Object.entries(result)) {
|
|
230
|
+
const count = Object.keys(map).length;
|
|
231
|
+
const missing = allEntries.length - count;
|
|
232
|
+
lines.push(`[${loc}]: ${count} keys${missing > 0 ? ` (${missing} missing)` : ""}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
lines.push(``, `JSON export:`);
|
|
236
|
+
lines.push(JSON.stringify(result, null, 2));
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
240
|
+
};
|
|
241
|
+
} catch (error) {
|
|
242
|
+
return errorContent(error);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// ─── bulk_import ───────────────────────────────────────────────────────────
|
|
248
|
+
server.tool(
|
|
249
|
+
"bulk_import",
|
|
250
|
+
"Import multiple translation keys into the sandbox at once from a JSON map. Input format: { \"locale\": { \"key\": \"value\" } }. All writes go to sandbox — production is unchanged until you promote. Use this to migrate a module's local translation files to the server.",
|
|
251
|
+
{
|
|
252
|
+
projectSlug: z.string().describe("Project slug"),
|
|
253
|
+
namespace: z.string().describe("Namespace slug — must already exist (use create_namespace first)"),
|
|
254
|
+
translations: z
|
|
255
|
+
.record(
|
|
256
|
+
z.string(),
|
|
257
|
+
z.record(z.string(), z.string()),
|
|
258
|
+
)
|
|
259
|
+
.describe('Locale → key → value map. Example: { "en": { "save": "Save" }, "nb-NO": { "save": "Lagre" } }'),
|
|
260
|
+
dryRun: z
|
|
261
|
+
.boolean()
|
|
262
|
+
.default(false)
|
|
263
|
+
.describe("If true, validate and preview what would be imported without writing anything"),
|
|
264
|
+
},
|
|
265
|
+
async ({ projectSlug, namespace, translations, dryRun }) => {
|
|
266
|
+
try {
|
|
267
|
+
// Validate locale codes against the project. Unknown codes are rejected — not remapped.
|
|
268
|
+
// Agent must call get_project_details first to get the exact codes for this project.
|
|
269
|
+
const project = await apiGet<{ locales: { code: string; isDefault: boolean }[] }>(
|
|
270
|
+
`/translations/projects/${projectSlug}`,
|
|
271
|
+
);
|
|
272
|
+
const validLocales = new Set(project.locales.map((l) => l.code));
|
|
273
|
+
const requestedLocales = Object.keys(translations);
|
|
274
|
+
const unknownLocales = requestedLocales.filter((l) => !validLocales.has(l));
|
|
275
|
+
|
|
276
|
+
if (unknownLocales.length > 0) {
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{
|
|
280
|
+
type: "text" as const,
|
|
281
|
+
text: [
|
|
282
|
+
`❌ Import aborted — locale codes do not match the project.`,
|
|
283
|
+
``,
|
|
284
|
+
`Unknown codes: ${unknownLocales.map((l) => `"${l}"`).join(", ")}`,
|
|
285
|
+
`Valid locales for "${projectSlug}": ${[...validLocales].join(", ")}`,
|
|
286
|
+
``,
|
|
287
|
+
`Call get_project_details to get the exact locale codes. Do not guess or remap them.`,
|
|
288
|
+
`If the locale does not exist in the project yet, call create_locale first.`,
|
|
289
|
+
].join("\n"),
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Collect all unique keys across all locales
|
|
296
|
+
const allKeys = new Set<string>();
|
|
297
|
+
for (const localeMap of Object.values(translations)) {
|
|
298
|
+
for (const key of Object.keys(localeMap)) {
|
|
299
|
+
allKeys.add(key);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (dryRun) {
|
|
304
|
+
const perLocale = requestedLocales.map(
|
|
305
|
+
(l) => ` ${l}: ${Object.keys(translations[l]).length} values`,
|
|
306
|
+
);
|
|
307
|
+
return {
|
|
308
|
+
content: [
|
|
309
|
+
{
|
|
310
|
+
type: "text" as const,
|
|
311
|
+
text: [
|
|
312
|
+
`DRY RUN — nothing written`,
|
|
313
|
+
``,
|
|
314
|
+
`Would import to sandbox: ${projectSlug}/${namespace}`,
|
|
315
|
+
` Unique keys: ${allKeys.size}`,
|
|
316
|
+
` Locales:`,
|
|
317
|
+
...perLocale,
|
|
318
|
+
].join("\n"),
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const basePath = `/translations/projects/${projectSlug}/sandbox/namespaces/${namespace}/entries`;
|
|
325
|
+
|
|
326
|
+
// Group values per key across all locales, then upsert each key once
|
|
327
|
+
const keyMap = new Map<string, Record<string, string>>();
|
|
328
|
+
for (const [locale, localeMap] of Object.entries(translations)) {
|
|
329
|
+
for (const [key, value] of Object.entries(localeMap)) {
|
|
330
|
+
if (!keyMap.has(key)) keyMap.set(key, {});
|
|
331
|
+
keyMap.get(key)![locale] = value;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let created = 0;
|
|
336
|
+
let updated = 0;
|
|
337
|
+
let failed = 0;
|
|
338
|
+
const errors: string[] = [];
|
|
339
|
+
|
|
340
|
+
const { apiPatch } = await import("../api-client.js");
|
|
341
|
+
|
|
342
|
+
for (const [key, values] of keyMap.entries()) {
|
|
343
|
+
try {
|
|
344
|
+
// Try PATCH (update) first — faster if key exists
|
|
345
|
+
try {
|
|
346
|
+
await apiPatch<unknown>(`${basePath}/${encodeURIComponent(key)}`, { values });
|
|
347
|
+
updated++;
|
|
348
|
+
} catch (patchErr) {
|
|
349
|
+
if (patchErr instanceof ApiError && patchErr.status === 404) {
|
|
350
|
+
// Key does not exist yet — create it
|
|
351
|
+
await apiPost<unknown>(basePath, { key, values });
|
|
352
|
+
created++;
|
|
353
|
+
} else {
|
|
354
|
+
throw patchErr;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch (err) {
|
|
358
|
+
failed++;
|
|
359
|
+
if (err instanceof ApiError) {
|
|
360
|
+
errors.push(` ${key}: ${err.status} ${err.message}`);
|
|
361
|
+
} else {
|
|
362
|
+
errors.push(` ${key}: ${String(err)}`);
|
|
363
|
+
}
|
|
364
|
+
if (failed > 5) {
|
|
365
|
+
errors.push(` ... and more errors (stopping early)`);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
logWrite("bulk_import", { projectSlug, namespace, keyCount: keyMap.size }, { created, updated, failed });
|
|
372
|
+
|
|
373
|
+
const lines = [
|
|
374
|
+
`Bulk import to sandbox: ${projectSlug}/${namespace}`,
|
|
375
|
+
` Created: ${created}`,
|
|
376
|
+
` Updated: ${updated}`,
|
|
377
|
+
` Failed: ${failed}`,
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
if (errors.length > 0) {
|
|
381
|
+
lines.push(``, `Errors:`, ...errors);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
lines.push(
|
|
385
|
+
``,
|
|
386
|
+
`Use get_translation_diff to review all pending changes before promoting.`,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
391
|
+
};
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return errorContent(error);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// ─── get_namespace_coverage ────────────────────────────────────────────────
|
|
399
|
+
server.tool(
|
|
400
|
+
"get_namespace_coverage",
|
|
401
|
+
[
|
|
402
|
+
"Show per-locale fill statistics for a namespace.",
|
|
403
|
+
"Returns: total keys, how many have a non-empty value for each locale, and coverage percentage.",
|
|
404
|
+
"Also lists the first 10 keys missing each locale.",
|
|
405
|
+
"Use this after adding a new locale to understand the fill gap before starting bulk_set_locale.",
|
|
406
|
+
].join(" "),
|
|
407
|
+
{
|
|
408
|
+
projectSlug: z.string().describe("Project slug"),
|
|
409
|
+
namespace: z.string().describe("Namespace slug"),
|
|
410
|
+
env: z
|
|
411
|
+
.enum(["sandbox", "production"])
|
|
412
|
+
.default("sandbox")
|
|
413
|
+
.describe("Which environment to analyze (default: sandbox)"),
|
|
414
|
+
},
|
|
415
|
+
async ({ projectSlug, namespace, env }) => {
|
|
416
|
+
try {
|
|
417
|
+
// Fetch project locales
|
|
418
|
+
const project = await apiGet<{ locales: { code: string; isDefault: boolean }[] }>(
|
|
419
|
+
`/translations/projects/${projectSlug}`,
|
|
420
|
+
);
|
|
421
|
+
const localeCodes = project.locales.map((l) => l.code);
|
|
422
|
+
|
|
423
|
+
if (localeCodes.length === 0) {
|
|
424
|
+
return {
|
|
425
|
+
content: [{ type: "text" as const, text: `Project "${projectSlug}" has no locales configured.` }],
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Fetch all entries
|
|
430
|
+
const basePath =
|
|
431
|
+
env === "sandbox"
|
|
432
|
+
? `/translations/projects/${projectSlug}/sandbox/namespaces/${namespace}/entries`
|
|
433
|
+
: `/translations/projects/${projectSlug}/namespaces/${namespace}/entries`;
|
|
434
|
+
|
|
435
|
+
const allEntries: TranslationEntry[] = [];
|
|
436
|
+
let page = 1;
|
|
437
|
+
while (true) {
|
|
438
|
+
const data = await apiGet<EntriesPage>(basePath, { page, limit: 100 });
|
|
439
|
+
allEntries.push(...data.data);
|
|
440
|
+
if (page >= data.meta.totalPages) break;
|
|
441
|
+
page++;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (allEntries.length === 0) {
|
|
445
|
+
return {
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "text" as const,
|
|
449
|
+
text: `Namespace ${projectSlug}/${namespace} [${env}] is empty. No keys to analyze.`,
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const totalKeys = allEntries.length;
|
|
456
|
+
|
|
457
|
+
// Build per-locale stats
|
|
458
|
+
const localeStats = localeCodes.map((code) => {
|
|
459
|
+
const filled = allEntries.filter(
|
|
460
|
+
(e) => e.values[code] !== undefined && e.values[code].trim() !== "",
|
|
461
|
+
);
|
|
462
|
+
const missingKeys = allEntries
|
|
463
|
+
.filter((e) => !e.values[code] || e.values[code].trim() === "")
|
|
464
|
+
.map((e) => e.key);
|
|
465
|
+
const pct = Math.round((filled.length / totalKeys) * 100);
|
|
466
|
+
return { code, filled: filled.length, missing: missingKeys.length, pct, missingKeys };
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const lines = [
|
|
470
|
+
`Namespace coverage: ${projectSlug}/${namespace} [${env}]`,
|
|
471
|
+
`Total keys: ${totalKeys}`,
|
|
472
|
+
``,
|
|
473
|
+
`Locale coverage:`,
|
|
474
|
+
...localeStats.map((s) => {
|
|
475
|
+
const bar = "█".repeat(Math.round(s.pct / 5)) + "░".repeat(20 - Math.round(s.pct / 5));
|
|
476
|
+
return ` ${s.code.padEnd(10)} ${bar} ${s.pct}% (${s.filled}/${totalKeys})${s.missing > 0 ? ` — ${s.missing} missing` : ""}`;
|
|
477
|
+
}),
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
const incomplete = localeStats.filter((s) => s.missing > 0);
|
|
481
|
+
if (incomplete.length > 0) {
|
|
482
|
+
lines.push(``, `Sample missing keys (up to 10 per locale):`);
|
|
483
|
+
for (const s of incomplete) {
|
|
484
|
+
if (s.missingKeys.length > 0) {
|
|
485
|
+
lines.push(` [${s.code}]:`);
|
|
486
|
+
s.missingKeys.slice(0, 10).forEach((k) => lines.push(` • ${k}`));
|
|
487
|
+
if (s.missingKeys.length > 10) lines.push(` ... and ${s.missingKeys.length - 10} more`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
lines.push(
|
|
491
|
+
``,
|
|
492
|
+
`To fill gaps: use list_translations with missingLocale="<code>" then bulk_set_locale.`,
|
|
493
|
+
);
|
|
494
|
+
} else {
|
|
495
|
+
lines.push(``, `All locales are fully covered.`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
500
|
+
};
|
|
501
|
+
} catch (error) {
|
|
502
|
+
return errorContent(error);
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function errorContent(error: unknown): { content: { type: "text"; text: string }[] } {
|
|
509
|
+
if (error instanceof ApiError) {
|
|
510
|
+
return {
|
|
511
|
+
content: [{ type: "text" as const, text: `Error ${error.status}: ${error.message}` }],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
content: [{ type: "text" as const, text: `Unexpected error: ${String(error)}` }],
|
|
516
|
+
};
|
|
517
|
+
}
|