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,321 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { apiGet, apiPost, apiPatch, apiDelete, ApiError } from "../api-client.js";
|
|
4
|
+
import { assertSandboxWrite } from "../permissions.js";
|
|
5
|
+
import { logWrite } from "../logger.js";
|
|
6
|
+
|
|
7
|
+
interface EntryRow {
|
|
8
|
+
key: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
values: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ProjectDetails {
|
|
14
|
+
locales: { code: string; isDefault: boolean }[];
|
|
15
|
+
namespaces: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates that all requested locale codes exist in the project.
|
|
20
|
+
* Returns valid and unknown sets so callers can surface errors to the agent.
|
|
21
|
+
*
|
|
22
|
+
* Does NOT remap or normalise codes — unknown codes are rejected, not guessed.
|
|
23
|
+
*/
|
|
24
|
+
async function validateLocales(
|
|
25
|
+
projectSlug: string,
|
|
26
|
+
requestedLocales: string[],
|
|
27
|
+
): Promise<{ valid: string[]; unknown: string[] }> {
|
|
28
|
+
try {
|
|
29
|
+
const project = await apiGet<ProjectDetails>(`/translations/projects/${projectSlug}`);
|
|
30
|
+
const projectLocales = new Set(project.locales.map((l) => l.code));
|
|
31
|
+
const valid = requestedLocales.filter((l) => projectLocales.has(l));
|
|
32
|
+
const unknown = requestedLocales.filter((l) => !projectLocales.has(l));
|
|
33
|
+
return { valid, unknown };
|
|
34
|
+
} catch {
|
|
35
|
+
// If we can't fetch the project, proceed — the backend will reject invalid codes.
|
|
36
|
+
return { valid: requestedLocales, unknown: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerSandboxWriteTools(server: McpServer): void {
|
|
41
|
+
// ─── set_translation ────────────────────────────────────────────────────────
|
|
42
|
+
server.tool(
|
|
43
|
+
"set_translation",
|
|
44
|
+
[
|
|
45
|
+
"Create or update a translation key in the sandbox (upsert).",
|
|
46
|
+
"PARTIAL LOCALE UPDATE: Pass only the locale(s) you want to update — other locales are untouched.",
|
|
47
|
+
"Example: values={ 'nb-NO': 'Lagre' } updates only Norwegian, leaving en/sv/da-DK unchanged.",
|
|
48
|
+
"This is the correct flow for adding a single locale to an existing key.",
|
|
49
|
+
"Locale codes must exactly match the project's locale codes — call get_project_details first.",
|
|
50
|
+
"Invalid codes are rejected, not auto-corrected. Always writes to sandbox only.",
|
|
51
|
+
"For updating many keys at once, use bulk_set_locale (single locale) or bulk_import (multiple locales).",
|
|
52
|
+
].join(" "),
|
|
53
|
+
{
|
|
54
|
+
projectSlug: z.string().describe("Project slug"),
|
|
55
|
+
namespace: z.string().describe("Namespace slug (e.g. 'backoffice-translations')"),
|
|
56
|
+
key: z
|
|
57
|
+
.string()
|
|
58
|
+
.regex(/^[a-zA-Z0-9._-]+$/, "Key must contain only letters, digits, dots, underscores or dashes")
|
|
59
|
+
.describe("Translation key name (e.g. 'button.save', 'errors.notFound')"),
|
|
60
|
+
values: z
|
|
61
|
+
.record(z.string(), z.string())
|
|
62
|
+
.describe(
|
|
63
|
+
"Locale-to-value map. Pass only the locales you want to set — other locales are preserved. " +
|
|
64
|
+
"Locale codes must match the project exactly (e.g. { \"nb-NO\": \"Lagre\" } or { \"nb-NO\": \"Lagre\", \"en\": \"Save\" }).",
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
async ({ projectSlug, namespace, key, values }) => {
|
|
68
|
+
assertSandboxWrite("sandbox");
|
|
69
|
+
|
|
70
|
+
// Validate locale codes against project — unknown codes are rejected.
|
|
71
|
+
const { unknown: unknownLocales } = await validateLocales(projectSlug, Object.keys(values));
|
|
72
|
+
if (unknownLocales.length > 0) {
|
|
73
|
+
return {
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "text" as const,
|
|
77
|
+
text: [
|
|
78
|
+
`❌ Invalid locale codes — call get_project_details to get the exact codes for this project.`,
|
|
79
|
+
``,
|
|
80
|
+
`Unknown codes: ${unknownLocales.map((l) => `"${l}"`).join(", ")}`,
|
|
81
|
+
``,
|
|
82
|
+
`Do not guess or remap locale codes. Use only what get_project_details returns.`,
|
|
83
|
+
].join("\n"),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const basePath = `/translations/projects/${projectSlug}/sandbox/namespaces/${namespace}/entries`;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const updated = await apiPatch<EntryRow>(`${basePath}/${encodeURIComponent(key)}`, { values });
|
|
93
|
+
logWrite("set_translation", { projectSlug, namespace, key, action: "updated" }, updated);
|
|
94
|
+
return successContent("Updated", projectSlug, namespace, key, updated.values);
|
|
95
|
+
} catch (updateError) {
|
|
96
|
+
if (!(updateError instanceof ApiError) || updateError.status !== 404) {
|
|
97
|
+
return errorContent(updateError);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const created = await apiPost<EntryRow>(basePath, { key, values });
|
|
102
|
+
logWrite("set_translation", { projectSlug, namespace, key, action: "created" }, created);
|
|
103
|
+
return successContent("Created", projectSlug, namespace, key, created.values);
|
|
104
|
+
} catch (createError) {
|
|
105
|
+
if (createError instanceof ApiError && createError.status === 409) {
|
|
106
|
+
try {
|
|
107
|
+
const retried = await apiPatch<EntryRow>(`${basePath}/${encodeURIComponent(key)}`, { values });
|
|
108
|
+
logWrite("set_translation", { projectSlug, namespace, key, action: "updated" }, retried);
|
|
109
|
+
return successContent("Updated", projectSlug, namespace, key, retried.values);
|
|
110
|
+
} catch (retryError) {
|
|
111
|
+
return errorContent(retryError);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return errorContent(createError);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// ─── bulk_set_locale ────────────────────────────────────────────────────────
|
|
121
|
+
server.tool(
|
|
122
|
+
"bulk_set_locale",
|
|
123
|
+
[
|
|
124
|
+
"Bulk upsert multiple keys for a SINGLE locale in the sandbox.",
|
|
125
|
+
"Designed for the new-locale fill workflow: after adding a locale, use this to fill many keys at once.",
|
|
126
|
+
"Only the specified locale is written — all other locales on each key remain untouched.",
|
|
127
|
+
"Use list_translations with missingLocale to get the list of keys to fill.",
|
|
128
|
+
"For multi-locale bulk import use bulk_import instead.",
|
|
129
|
+
].join(" "),
|
|
130
|
+
{
|
|
131
|
+
projectSlug: z.string().describe("Project slug"),
|
|
132
|
+
namespace: z.string().describe("Namespace slug — must already exist"),
|
|
133
|
+
locale: z
|
|
134
|
+
.string()
|
|
135
|
+
.describe("The single locale to write values for (e.g. 'nb-NO'). Must match the project's locale codes exactly."),
|
|
136
|
+
entries: z
|
|
137
|
+
.array(
|
|
138
|
+
z.object({
|
|
139
|
+
key: z
|
|
140
|
+
.string()
|
|
141
|
+
.regex(/^[a-zA-Z0-9._-]+$/, "Key must contain only letters, digits, dots, underscores or dashes"),
|
|
142
|
+
value: z.string(),
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
.min(1)
|
|
146
|
+
.describe("Array of { key, value } pairs to upsert for the given locale."),
|
|
147
|
+
dryRun: z
|
|
148
|
+
.boolean()
|
|
149
|
+
.default(false)
|
|
150
|
+
.describe("If true, preview what would be written without actually writing"),
|
|
151
|
+
},
|
|
152
|
+
async ({ projectSlug, namespace, locale, entries, dryRun }) => {
|
|
153
|
+
assertSandboxWrite("sandbox");
|
|
154
|
+
|
|
155
|
+
// Validate locale code against project.
|
|
156
|
+
const { unknown: unknownLocales } = await validateLocales(projectSlug, [locale]);
|
|
157
|
+
if (unknownLocales.length > 0) {
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{
|
|
161
|
+
type: "text" as const,
|
|
162
|
+
text: [
|
|
163
|
+
`❌ Invalid locale code "${locale}" — call get_project_details to get the exact codes for this project.`,
|
|
164
|
+
`Do not guess or remap locale codes.`,
|
|
165
|
+
].join("\n"),
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (dryRun) {
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text" as const,
|
|
176
|
+
text: [
|
|
177
|
+
`DRY RUN — nothing written`,
|
|
178
|
+
``,
|
|
179
|
+
`Would write to sandbox: ${projectSlug}/${namespace} [locale: ${locale}]`,
|
|
180
|
+
` Keys: ${entries.length}`,
|
|
181
|
+
``,
|
|
182
|
+
entries.slice(0, 10).map((e) => ` ${e.key}: "${e.value}"`).join("\n"),
|
|
183
|
+
entries.length > 10 ? ` ... and ${entries.length - 10} more` : "",
|
|
184
|
+
].filter(Boolean).join("\n"),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const basePath = `/translations/projects/${projectSlug}/sandbox/namespaces/${namespace}/entries`;
|
|
191
|
+
|
|
192
|
+
let created = 0;
|
|
193
|
+
let updated = 0;
|
|
194
|
+
let failed = 0;
|
|
195
|
+
const errors: string[] = [];
|
|
196
|
+
|
|
197
|
+
for (const { key, value } of entries) {
|
|
198
|
+
try {
|
|
199
|
+
try {
|
|
200
|
+
await apiPatch<unknown>(`${basePath}/${encodeURIComponent(key)}`, { values: { [locale]: value } });
|
|
201
|
+
updated++;
|
|
202
|
+
} catch (patchErr) {
|
|
203
|
+
if (patchErr instanceof ApiError && patchErr.status === 404) {
|
|
204
|
+
await apiPost<unknown>(basePath, { key, values: { [locale]: value } });
|
|
205
|
+
created++;
|
|
206
|
+
} else {
|
|
207
|
+
throw patchErr;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
failed++;
|
|
212
|
+
errors.push(` ${key}: ${err instanceof ApiError ? `${err.status} ${err.message}` : String(err)}`);
|
|
213
|
+
if (failed > 5) {
|
|
214
|
+
errors.push(` ... stopping early after ${failed} errors`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
logWrite("bulk_set_locale", { projectSlug, namespace, locale, keyCount: entries.length }, { created, updated, failed });
|
|
221
|
+
|
|
222
|
+
const lines = [
|
|
223
|
+
`Bulk set locale "${locale}" in sandbox: ${projectSlug}/${namespace}`,
|
|
224
|
+
` Created: ${created}`,
|
|
225
|
+
` Updated: ${updated}`,
|
|
226
|
+
` Failed: ${failed}`,
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
if (errors.length > 0) {
|
|
230
|
+
lines.push(``, `Errors:`, ...errors);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
lines.push(
|
|
234
|
+
``,
|
|
235
|
+
`Use validate_translations or get_translation_diff to review pending changes.`,
|
|
236
|
+
`Use list_translations with missingLocale="${locale}" to check remaining gaps.`,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// ─── delete_translation ─────────────────────────────────────────────────────
|
|
246
|
+
server.tool(
|
|
247
|
+
"delete_translation",
|
|
248
|
+
"Delete a translation key from the sandbox (soft delete). The key remains in production until you promote the sandbox.",
|
|
249
|
+
{
|
|
250
|
+
projectSlug: z.string().describe("Project slug"),
|
|
251
|
+
namespace: z.string().describe("Namespace slug"),
|
|
252
|
+
key: z.string().describe("Translation key to delete"),
|
|
253
|
+
},
|
|
254
|
+
async ({ projectSlug, namespace, key }) => {
|
|
255
|
+
assertSandboxWrite("sandbox");
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await apiDelete(
|
|
259
|
+
`/translations/projects/${projectSlug}/sandbox/namespaces/${namespace}/entries/${encodeURIComponent(key)}`,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
logWrite("delete_translation", { projectSlug, namespace, key }, { deleted: true });
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
content: [
|
|
266
|
+
{
|
|
267
|
+
type: "text" as const,
|
|
268
|
+
text: [
|
|
269
|
+
`Deleted sandbox key: ${projectSlug}/${namespace}/${key}`,
|
|
270
|
+
``,
|
|
271
|
+
`Marked for deletion in sandbox. Removed from production only after promote via Admin UI.`,
|
|
272
|
+
`Use get_translation_diff to review the pending deletion.`,
|
|
273
|
+
].join("\n"),
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return errorContent(error);
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function successContent(
|
|
285
|
+
action: "Created" | "Updated",
|
|
286
|
+
projectSlug: string,
|
|
287
|
+
namespace: string,
|
|
288
|
+
key: string,
|
|
289
|
+
values: Record<string, string>,
|
|
290
|
+
): { content: { type: "text"; text: string }[] } {
|
|
291
|
+
const valueLines = Object.entries(values)
|
|
292
|
+
.map(([locale, val]) => ` [${locale}] ${val || "(empty)"}`)
|
|
293
|
+
.join("\n");
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
content: [
|
|
297
|
+
{
|
|
298
|
+
type: "text" as const,
|
|
299
|
+
text: [
|
|
300
|
+
`${action} sandbox key: ${projectSlug}/${namespace}/${key}`,
|
|
301
|
+
``,
|
|
302
|
+
`Values saved in sandbox:`,
|
|
303
|
+
valueLines || " (no values set)",
|
|
304
|
+
``,
|
|
305
|
+
`Production is unchanged. Use preview_push_to_production to review before promoting.`,
|
|
306
|
+
].join("\n"),
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function errorContent(error: unknown): { content: { type: "text"; text: string }[] } {
|
|
313
|
+
if (error instanceof ApiError) {
|
|
314
|
+
return {
|
|
315
|
+
content: [{ type: "text" as const, text: `Error ${error.status}: ${error.message}` }],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: "text" as const, text: `Unexpected error: ${String(error)}` }],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { apiGet, ApiError } from "../api-client.js";
|
|
4
|
+
|
|
5
|
+
interface SnapshotItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string | null;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
entryCount: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerSnapshotTools(server: McpServer): void {
|
|
13
|
+
server.tool(
|
|
14
|
+
"list_snapshots",
|
|
15
|
+
"List available production snapshots for a project. Snapshots are created automatically before each push to production and can be used to revert.",
|
|
16
|
+
{
|
|
17
|
+
projectSlug: z.string().describe("Project slug"),
|
|
18
|
+
},
|
|
19
|
+
async ({ projectSlug }) => {
|
|
20
|
+
try {
|
|
21
|
+
const snapshots = await apiGet<SnapshotItem[]>(
|
|
22
|
+
`/translations/projects/${projectSlug}/sandbox/snapshots`,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (snapshots.length === 0) {
|
|
26
|
+
return {
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "text" as const,
|
|
30
|
+
text: `No snapshots available for project "${projectSlug}".\n\nSnapshots are created automatically when sandbox changes are pushed to production.`,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rows = snapshots.map((s, i) => {
|
|
37
|
+
const date = new Date(s.createdAt).toLocaleString("en-GB", {
|
|
38
|
+
dateStyle: "short",
|
|
39
|
+
timeStyle: "short",
|
|
40
|
+
});
|
|
41
|
+
return `${i + 1}. ${s.label ?? "(no label)"}\n ID: ${s.id}\n Created: ${date}\n Entries: ${s.entryCount}`;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
content: [
|
|
46
|
+
{
|
|
47
|
+
type: "text" as const,
|
|
48
|
+
text: `Snapshots for "${projectSlug}" (${snapshots.length} available, max 5):\n\n${rows.join("\n\n")}`,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return errorContent(error);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function errorContent(error: unknown): { content: { type: "text"; text: string }[] } {
|
|
60
|
+
if (error instanceof ApiError) {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text" as const, text: `Error ${error.status}: ${error.message}` }],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text" as const, text: `Unexpected error: ${String(error)}` }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { apiGet, ApiError } from "../api-client.js";
|
|
4
|
+
|
|
5
|
+
interface TranslationEntry {
|
|
6
|
+
key: string;
|
|
7
|
+
values: Record<string, string>;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface EntriesResponse {
|
|
12
|
+
data: TranslationEntry[];
|
|
13
|
+
meta: { total: number; page: number; limit: number; totalPages: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerTranslationTools(server: McpServer): void {
|
|
17
|
+
server.tool(
|
|
18
|
+
"list_translations",
|
|
19
|
+
[
|
|
20
|
+
"Browse translation entries in a namespace.",
|
|
21
|
+
"Use env='sandbox' (default) to see the working sandbox view, or env='production' to see what is currently live.",
|
|
22
|
+
"Supports pagination and full-text search.",
|
|
23
|
+
"Use missingLocale to find all keys that do not have a value for a specific locale — essential for the new-locale fill workflow.",
|
|
24
|
+
"Example: missingLocale='nb-NO' returns only keys where Norwegian is empty or absent.",
|
|
25
|
+
].join(" "),
|
|
26
|
+
{
|
|
27
|
+
projectSlug: z.string().describe("Project slug"),
|
|
28
|
+
namespace: z.string().describe("Namespace slug (e.g. 'common', 'backoffice')"),
|
|
29
|
+
env: z
|
|
30
|
+
.enum(["sandbox", "production"])
|
|
31
|
+
.default("sandbox")
|
|
32
|
+
.describe("Which environment to read from. Default: sandbox (the editable working copy)."),
|
|
33
|
+
page: z.number().int().min(1).default(1).describe("Page number (default: 1)"),
|
|
34
|
+
limit: z.number().int().min(1).max(100).default(50).describe("Items per page (default: 50, max: 100)"),
|
|
35
|
+
search: z.string().optional().describe("Search string (min 2 chars) — searches keys and values"),
|
|
36
|
+
searchLocale: z.string().optional().describe("Restrict value search to a specific locale (e.g. 'en')"),
|
|
37
|
+
missingLocale: z
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe(
|
|
41
|
+
"Return only entries where this locale has no value or an empty value. " +
|
|
42
|
+
"Use this to find gaps after adding a new locale. Locale code must match the project exactly.",
|
|
43
|
+
),
|
|
44
|
+
sortBy: z.enum(["key", "createdAt"]).default("key").describe("Sort field"),
|
|
45
|
+
sortOrder: z.enum(["asc", "desc"]).default("asc").describe("Sort direction"),
|
|
46
|
+
},
|
|
47
|
+
async ({ projectSlug, namespace, env, page, limit, search, searchLocale, missingLocale, sortBy, sortOrder }) => {
|
|
48
|
+
try {
|
|
49
|
+
// When filtering by missingLocale we must fetch all pages to filter correctly,
|
|
50
|
+
// then re-paginate manually. Otherwise we fetch a single page and return it directly.
|
|
51
|
+
const basePath =
|
|
52
|
+
env === "sandbox"
|
|
53
|
+
? `/translations/projects/${projectSlug}/sandbox/namespaces/${namespace}/entries`
|
|
54
|
+
: `/translations/projects/${projectSlug}/namespaces/${namespace}/entries`;
|
|
55
|
+
|
|
56
|
+
if (missingLocale) {
|
|
57
|
+
// Fetch all pages so we can filter by missing locale correctly.
|
|
58
|
+
const allEntries: TranslationEntry[] = [];
|
|
59
|
+
let currentPage = 1;
|
|
60
|
+
while (true) {
|
|
61
|
+
const params: Record<string, unknown> = { page: currentPage, limit: 100, sortBy, sortOrder };
|
|
62
|
+
if (search) params.search = search;
|
|
63
|
+
const data = await apiGet<EntriesResponse>(basePath, params);
|
|
64
|
+
allEntries.push(...data.data);
|
|
65
|
+
if (currentPage >= data.meta.totalPages) break;
|
|
66
|
+
currentPage++;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const missing = allEntries.filter(
|
|
70
|
+
(e) => !e.values[missingLocale] || e.values[missingLocale].trim() === "",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (missing.length === 0) {
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: "text" as const,
|
|
78
|
+
text:
|
|
79
|
+
`No missing entries for locale "${missingLocale}" in ${projectSlug}/${namespace} [${env}]. ` +
|
|
80
|
+
`All ${allEntries.length} keys have a value for this locale.`,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rows = missing.map((entry) => {
|
|
87
|
+
const otherValues = Object.entries(entry.values)
|
|
88
|
+
.filter(([l]) => l !== missingLocale)
|
|
89
|
+
.slice(0, 3)
|
|
90
|
+
.map(([locale, value]) => ` [${locale}] ${value || "(empty)"}`)
|
|
91
|
+
.join("\n");
|
|
92
|
+
return `${entry.key}:${otherValues ? `\n${otherValues}` : ""}`;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text" as const,
|
|
99
|
+
text: [
|
|
100
|
+
`Keys missing "${missingLocale}" in ${projectSlug}/${namespace} [${env}]: ${missing.length} of ${allEntries.length} total`,
|
|
101
|
+
``,
|
|
102
|
+
`Use bulk_set_locale or set_translation to fill these values.`,
|
|
103
|
+
``,
|
|
104
|
+
rows.join("\n\n"),
|
|
105
|
+
].join("\n"),
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Normal paginated fetch (no missingLocale filter).
|
|
112
|
+
const params: Record<string, unknown> = { page, limit, sortBy, sortOrder };
|
|
113
|
+
if (search) params.search = search;
|
|
114
|
+
if (searchLocale) params.searchLocale = searchLocale;
|
|
115
|
+
|
|
116
|
+
const data = await apiGet<EntriesResponse>(basePath, params);
|
|
117
|
+
|
|
118
|
+
if (data.data.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text" as const,
|
|
123
|
+
text:
|
|
124
|
+
`No entries found in ${projectSlug}/${namespace} [${env}]` +
|
|
125
|
+
(search ? ` matching "${search}"` : "") +
|
|
126
|
+
`. Total: 0.`,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rows = data.data.map((entry) => {
|
|
133
|
+
const valuesStr = Object.entries(entry.values)
|
|
134
|
+
.map(([locale, value]) => ` [${locale}] ${value || "(empty)"}`)
|
|
135
|
+
.join("\n");
|
|
136
|
+
return `${entry.key}:\n${valuesStr}`;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const header =
|
|
140
|
+
`Entries in ${projectSlug}/${namespace} [${env}] — page ${data.meta.page}, ` +
|
|
141
|
+
`showing ${data.data.length} of ${data.meta.total}${search ? ` (search: "${search}")` : ""}`;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
content: [
|
|
145
|
+
{
|
|
146
|
+
type: "text" as const,
|
|
147
|
+
text: `${header}\n\n${rows.join("\n\n")}`,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return errorContent(error);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function errorContent(error: unknown): { content: { type: "text"; text: string }[] } {
|
|
159
|
+
if (error instanceof ApiError) {
|
|
160
|
+
return {
|
|
161
|
+
content: [{ type: "text" as const, text: `Error ${error.status}: ${error.message}` }],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
content: [{ type: "text" as const, text: `Unexpected error: ${String(error)}` }],
|
|
166
|
+
};
|
|
167
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|