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.
Files changed (76) hide show
  1. package/.env.example +9 -0
  2. package/AGENT_GUIDE.md +556 -0
  3. package/AUDIT_REPORT.md +244 -0
  4. package/PROJECT_OVERVIEW.md +140 -0
  5. package/dist/api-client.d.ts +11 -0
  6. package/dist/api-client.d.ts.map +1 -0
  7. package/dist/api-client.js +67 -0
  8. package/dist/api-client.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +21 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/locale-aliases.d.ts +46 -0
  14. package/dist/locale-aliases.d.ts.map +1 -0
  15. package/dist/locale-aliases.js +71 -0
  16. package/dist/locale-aliases.js.map +1 -0
  17. package/dist/logger.d.ts +8 -0
  18. package/dist/logger.d.ts.map +1 -0
  19. package/dist/logger.js +19 -0
  20. package/dist/logger.js.map +1 -0
  21. package/dist/permissions.d.ts +127 -0
  22. package/dist/permissions.d.ts.map +1 -0
  23. package/dist/permissions.js +75 -0
  24. package/dist/permissions.js.map +1 -0
  25. package/dist/prompts.d.ts +3 -0
  26. package/dist/prompts.d.ts.map +1 -0
  27. package/dist/prompts.js +129 -0
  28. package/dist/prompts.js.map +1 -0
  29. package/dist/server.d.ts +3 -0
  30. package/dist/server.d.ts.map +1 -0
  31. package/dist/server.js +25 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/tools/diff.d.ts +3 -0
  34. package/dist/tools/diff.d.ts.map +1 -0
  35. package/dist/tools/diff.js +166 -0
  36. package/dist/tools/diff.js.map +1 -0
  37. package/dist/tools/environment.d.ts +3 -0
  38. package/dist/tools/environment.d.ts.map +1 -0
  39. package/dist/tools/environment.js +113 -0
  40. package/dist/tools/environment.js.map +1 -0
  41. package/dist/tools/production.d.ts +3 -0
  42. package/dist/tools/production.d.ts.map +1 -0
  43. package/dist/tools/production.js +145 -0
  44. package/dist/tools/production.js.map +1 -0
  45. package/dist/tools/project-management.d.ts +3 -0
  46. package/dist/tools/project-management.d.ts.map +1 -0
  47. package/dist/tools/project-management.js +416 -0
  48. package/dist/tools/project-management.js.map +1 -0
  49. package/dist/tools/sandbox-writes.d.ts +3 -0
  50. package/dist/tools/sandbox-writes.d.ts.map +1 -0
  51. package/dist/tools/sandbox-writes.js +260 -0
  52. package/dist/tools/sandbox-writes.js.map +1 -0
  53. package/dist/tools/snapshots.d.ts +3 -0
  54. package/dist/tools/snapshots.d.ts.map +1 -0
  55. package/dist/tools/snapshots.js +50 -0
  56. package/dist/tools/snapshots.js.map +1 -0
  57. package/dist/tools/translations.d.ts +3 -0
  58. package/dist/tools/translations.d.ts.map +1 -0
  59. package/dist/tools/translations.js +135 -0
  60. package/dist/tools/translations.js.map +1 -0
  61. package/migrate-expenses.cjs +120 -0
  62. package/package.json +26 -0
  63. package/src/api-client.ts +68 -0
  64. package/src/index.ts +29 -0
  65. package/src/logger.ts +31 -0
  66. package/src/permissions.ts +89 -0
  67. package/src/prompts.ts +159 -0
  68. package/src/server.ts +27 -0
  69. package/src/tools/diff.ts +225 -0
  70. package/src/tools/environment.ts +175 -0
  71. package/src/tools/production.ts +196 -0
  72. package/src/tools/project-management.ts +517 -0
  73. package/src/tools/sandbox-writes.ts +321 -0
  74. package/src/tools/snapshots.ts +68 -0
  75. package/src/tools/translations.ts +167 -0
  76. package/tsconfig.json +17 -0
@@ -0,0 +1,68 @@
1
+ import axios, { AxiosError } from "axios";
2
+
3
+ const client = axios.create({
4
+ baseURL: process.env.BACKEND_URL ?? "http://localhost:8080",
5
+ headers: {
6
+ "Content-Type": "application/json",
7
+ Authorization: `Bearer ${process.env.MCP_TOKEN ?? ""}`,
8
+ },
9
+ timeout: 30_000,
10
+ });
11
+
12
+ export class ApiError extends Error {
13
+ constructor(
14
+ public readonly status: number,
15
+ public readonly message: string,
16
+ public readonly details?: unknown,
17
+ ) {
18
+ super(message);
19
+ this.name = "ApiError";
20
+ }
21
+ }
22
+
23
+ function handleError(error: unknown): never {
24
+ if (error instanceof AxiosError) {
25
+ const status = error.response?.status ?? 0;
26
+ const message =
27
+ (error.response?.data as { message?: string })?.message ??
28
+ error.message ??
29
+ "Unknown API error";
30
+ throw new ApiError(status, String(message), error.response?.data);
31
+ }
32
+ throw error;
33
+ }
34
+
35
+ export async function apiGet<T>(path: string, params?: Record<string, unknown>): Promise<T> {
36
+ try {
37
+ const response = await client.get<T>(path, { params });
38
+ return response.data;
39
+ } catch (error) {
40
+ handleError(error);
41
+ }
42
+ }
43
+
44
+ export async function apiPost<T>(path: string, data?: unknown): Promise<T> {
45
+ try {
46
+ const response = await client.post<T>(path, data);
47
+ return response.data;
48
+ } catch (error) {
49
+ handleError(error);
50
+ }
51
+ }
52
+
53
+ export async function apiPatch<T>(path: string, data?: unknown): Promise<T> {
54
+ try {
55
+ const response = await client.patch<T>(path, data);
56
+ return response.data;
57
+ } catch (error) {
58
+ handleError(error);
59
+ }
60
+ }
61
+
62
+ export async function apiDelete(path: string): Promise<void> {
63
+ try {
64
+ await client.delete(path);
65
+ } catch (error) {
66
+ handleError(error);
67
+ }
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer } from "./server.js";
4
+
5
+ // Load .env from the mcp-server directory (one level above dist/)
6
+ if (process.env.NODE_ENV !== "production") {
7
+ const { default: dotenv } = await import("dotenv");
8
+ const { fileURLToPath } = await import("url");
9
+ const { dirname, resolve } = await import("path");
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ dotenv.config({ path: resolve(__dirname, "..", ".env") });
12
+ }
13
+
14
+ if (!process.env.MCP_TOKEN) {
15
+ process.stderr.write(
16
+ "[localization-mcp] WARNING: MCP_TOKEN is not set. All API calls will fail with 401.\n",
17
+ );
18
+ }
19
+
20
+ if (!process.env.BACKEND_URL) {
21
+ process.stderr.write(
22
+ "[localization-mcp] BACKEND_URL not set, defaulting to http://localhost:3000\n",
23
+ );
24
+ }
25
+
26
+ const server = createServer();
27
+ const transport = new StdioServerTransport();
28
+
29
+ await server.connect(transport);
package/src/logger.ts ADDED
@@ -0,0 +1,31 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const LOG_FILE =
5
+ process.env.AUDIT_LOG_PATH ?? path.join(process.cwd(), "mcp-audit.log");
6
+
7
+ export interface AuditEntry {
8
+ timestamp: string;
9
+ operation: string;
10
+ params: Record<string, unknown>;
11
+ result: unknown;
12
+ }
13
+
14
+ export function logWrite(
15
+ operation: string,
16
+ params: Record<string, unknown>,
17
+ result: unknown,
18
+ ): void {
19
+ const entry: AuditEntry = {
20
+ timestamp: new Date().toISOString(),
21
+ operation,
22
+ params,
23
+ result,
24
+ };
25
+ try {
26
+ fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n");
27
+ } catch {
28
+ // Log to stderr if file write fails — do not crash the MCP server
29
+ process.stderr.write(`[audit] Failed to write to ${LOG_FILE}: ${JSON.stringify(entry)}\n`);
30
+ }
31
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * MCP Server — Environment Access Policy
3
+ *
4
+ * Policy:
5
+ * sandbox → full read + write (all tools available)
6
+ * production → read-only (no production-write tools exist by design)
7
+ *
8
+ * This file is the single source of truth for which tools write where.
9
+ * All write tools are hardcoded to sandbox API endpoints.
10
+ * There are no production-write endpoints in this MCP server — intentionally.
11
+ *
12
+ * ── Where this logic lives ───────────────────────────────────────────────────
13
+ * mcp-server/src/permissions.ts ← this file (policy + registry + guard)
14
+ * mcp-server/src/tools/sandbox-writes.ts ← write tools (always sandbox)
15
+ * mcp-server/src/tools/translations.ts ← list_translations (env param)
16
+ *
17
+ * ── How to verify ────────────────────────────────────────────────────────────
18
+ * Sandbox write:
19
+ * set_translation({ projectSlug, namespace, key, values: { en: "Hello" } })
20
+ * → should succeed and appear in get_translation_diff
21
+ *
22
+ * Production read-only:
23
+ * list_translations({ ..., env: "production" }) → OK (read allowed)
24
+ * set_translation does not accept an env param → always sandbox
25
+ *
26
+ * Guard:
27
+ * assertSandboxWrite("production") → throws Error
28
+ * assertSandboxWrite("sandbox") → no-op
29
+ */
30
+
31
+ export type Environment = "sandbox" | "production";
32
+ export type Access = "read" | "write";
33
+ export type ToolEnv = "sandbox" | "production" | "both";
34
+
35
+ export interface ToolMeta {
36
+ env: ToolEnv;
37
+ access: Access;
38
+ }
39
+
40
+ /**
41
+ * Registry of all MCP tools with their environment scope and access level.
42
+ * Add every new tool here.
43
+ */
44
+ export const TOOL_REGISTRY = {
45
+ // ── Read — available for both environments ──────────────────────────
46
+ list_projects: { env: "both", access: "read" },
47
+ get_project_details: { env: "both", access: "read" },
48
+ get_environment_status: { env: "both", access: "read" },
49
+ list_translations: { env: "both", access: "read" },
50
+ get_translation_diff: { env: "both", access: "read" },
51
+ validate_translations: { env: "both", access: "read" },
52
+ list_snapshots: { env: "both", access: "read" },
53
+ preview_push_to_production: { env: "both", access: "read" },
54
+ // ── Write — sandbox only; no production-write tools exist ───────────
55
+ init_sandbox: { env: "sandbox", access: "write" },
56
+ reset_sandbox: { env: "sandbox", access: "write" },
57
+ set_translation: { env: "sandbox", access: "write" },
58
+ delete_translation: { env: "sandbox", access: "write" },
59
+ bulk_import: { env: "sandbox", access: "write" },
60
+ bulk_set_locale: { env: "sandbox", access: "write" },
61
+ // ── Project structure management ──────────────────────────────────────────
62
+ create_namespace: { env: "both", access: "write" },
63
+ create_locale: { env: "both", access: "write" },
64
+ // ── Read-only export / analysis ───────────────────────────────────────────
65
+ export_namespace: { env: "both", access: "read" },
66
+ get_namespace_coverage: { env: "both", access: "read" },
67
+ } as const satisfies Record<string, ToolMeta>;
68
+
69
+ export type ToolName = keyof typeof TOOL_REGISTRY;
70
+
71
+ /**
72
+ * Guard for write operations. Call at the start of every write tool handler.
73
+ * Throws a descriptive error if env is 'production', enforcing read-only production.
74
+ *
75
+ * @throws Error if env === 'production'
76
+ *
77
+ * @example
78
+ * assertSandboxWrite("sandbox"); // no-op
79
+ * assertSandboxWrite("production"); // throws
80
+ */
81
+ export function assertSandboxWrite(env: Environment): void {
82
+ if (env === "production") {
83
+ throw new Error(
84
+ "Production is read-only. " +
85
+ "All writes must target the sandbox environment. " +
86
+ "Make changes in sandbox, then promote to production via the Admin UI.",
87
+ );
88
+ }
89
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,159 @@
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 ProjectListItem {
6
+ id: string;
7
+ slug: string;
8
+ name: string | null;
9
+ sandboxInitializedAt: string | null;
10
+ sandboxHasChanges: boolean;
11
+ }
12
+
13
+ export function registerPrompts(server: McpServer): void {
14
+ // ─── /setup ─────────────────────────────────────────────────────────────────
15
+ server.prompt(
16
+ "setup",
17
+ "Quick-start guide: how to explore projects, work with sandbox, and push translations.",
18
+ {},
19
+ () => ({
20
+ messages: [
21
+ {
22
+ role: "user",
23
+ content: {
24
+ type: "text",
25
+ text: [
26
+ "Give me a quick-start guide for working with the localization MCP server.",
27
+ "Include: how to discover projects, namespaces, and locales; how to read and write translations in sandbox;",
28
+ "how to review and push changes to production. Keep it concise and practical.",
29
+ ].join(" "),
30
+ },
31
+ },
32
+ {
33
+ role: "assistant",
34
+ content: {
35
+ type: "text",
36
+ text: [
37
+ "## Localization MCP — Quick Start",
38
+ "",
39
+ "### 1. Discover",
40
+ "- `list_projects` — see all projects and sandbox state",
41
+ "- `get_project_details <slug>` — get exact locale codes and namespaces (**always call this before writing**)",
42
+ "",
43
+ "### 2. Read translations",
44
+ "- `list_translations <slug> <namespace>` — browse keys; filter with `missingLocale`, `search`",
45
+ "- `get_translation_diff <slug>` — see what changed in sandbox vs production",
46
+ "",
47
+ "### 3. Write to sandbox",
48
+ "- `set_translation` — upsert one key (pass only the locales you want to update)",
49
+ "- `bulk_set_locale` — fill many keys for a single locale at once",
50
+ "- `bulk_import` — import multiple locales from a JSON map",
51
+ "- `delete_translation` — soft-delete a key in sandbox",
52
+ "",
53
+ "### 4. Review & push",
54
+ "- `validate_translations <slug>` — check for missing translations before pushing",
55
+ "- `get_translation_diff <slug>` — final review of pending changes",
56
+ "- Promote via the Admin UI — MCP has no push tool by design (human approval required)",
57
+ "",
58
+ "### Rules",
59
+ "- All writes go to **sandbox only** — production is never touched directly",
60
+ "- Locale codes must match exactly what `get_project_details` returns — never guess",
61
+ "- Prefer existing namespaces — only create a new one with a clear justification",
62
+ ].join("\n"),
63
+ },
64
+ },
65
+ ],
66
+ }),
67
+ );
68
+
69
+ // ─── /diagnostic ────────────────────────────────────────────────────────────
70
+ server.prompt(
71
+ "diagnostic",
72
+ "Run a live health check: verify API connectivity, token validity, and list project states.",
73
+ { projectSlug: z.string().optional().describe("Optional: also check sandbox status for this project") },
74
+ async ({ projectSlug }) => {
75
+ const lines: string[] = ["## Localization MCP — Diagnostic", ""];
76
+
77
+ // 1. Config
78
+ const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8080 (default)";
79
+ const tokenSet = !!process.env.MCP_TOKEN;
80
+ lines.push("### Config");
81
+ lines.push(`- BACKEND_URL: \`${backendUrl}\``);
82
+ lines.push(`- MCP_TOKEN: ${tokenSet ? "set ✅" : "NOT SET ❌ — all API calls will fail with 401"}`);
83
+ lines.push("");
84
+
85
+ // 2. API connectivity + project list
86
+ lines.push("### API");
87
+ try {
88
+ const data = await apiGet<{ data: ProjectListItem[]; meta: { total: number } }>(
89
+ "/translations/projects",
90
+ { page: 1, limit: 100 },
91
+ );
92
+ lines.push(`- Connection: ✅ OK`);
93
+ lines.push(`- Token: ✅ valid`);
94
+ lines.push(`- Projects accessible: ${data.meta.total}`);
95
+ lines.push("");
96
+
97
+ if (data.data.length > 0) {
98
+ lines.push("### Projects");
99
+ for (const p of data.data) {
100
+ const sandboxState = p.sandboxInitializedAt
101
+ ? p.sandboxHasChanges
102
+ ? "sandbox: ⚠️ HAS PENDING CHANGES"
103
+ : "sandbox: ✅ no changes"
104
+ : "sandbox: not initialized";
105
+ lines.push(`- \`${p.slug}\`${p.name ? ` (${p.name})` : ""} — ${sandboxState}`);
106
+ }
107
+ lines.push("");
108
+ }
109
+
110
+ // 3. Optional per-project sandbox check
111
+ if (projectSlug) {
112
+ lines.push(`### Sandbox: ${projectSlug}`);
113
+ try {
114
+ const status = await apiGet<{
115
+ initialized: boolean;
116
+ initializedAt: string | null;
117
+ hasChanges: boolean;
118
+ snapshotCount: number;
119
+ }>(`/translations/projects/${projectSlug}/sandbox/status`);
120
+
121
+ lines.push(`- Initialized: ${status.initialized ? `✅ yes (since ${status.initializedAt})` : "❌ NO — call init_sandbox before writing"}`);
122
+ lines.push(`- Has pending changes: ${status.hasChanges ? "⚠️ YES" : "✅ no"}`);
123
+ lines.push(`- Snapshots available: ${status.snapshotCount}`);
124
+ } catch (err) {
125
+ lines.push(`- ❌ Could not fetch sandbox status: ${err instanceof ApiError ? `${err.status} ${err.message}` : String(err)}`);
126
+ }
127
+ lines.push("");
128
+ }
129
+ } catch (err) {
130
+ if (err instanceof ApiError) {
131
+ if (err.status === 401) {
132
+ lines.push(`- Connection: ✅ reached backend`);
133
+ lines.push(`- Token: ❌ INVALID or missing (401 Unauthorized)`);
134
+ lines.push(` → Regenerate token in Admin UI → API Tokens`);
135
+ } else {
136
+ lines.push(`- ❌ API error ${err.status}: ${err.message}`);
137
+ }
138
+ } else {
139
+ lines.push(`- ❌ Cannot reach backend at \`${backendUrl}\``);
140
+ lines.push(` → Is the backend running? Check BACKEND_URL env var.`);
141
+ }
142
+ lines.push("");
143
+ }
144
+
145
+ return {
146
+ messages: [
147
+ {
148
+ role: "user",
149
+ content: { type: "text", text: "Run diagnostic on the localization MCP server." },
150
+ },
151
+ {
152
+ role: "assistant",
153
+ content: { type: "text", text: lines.join("\n") },
154
+ },
155
+ ],
156
+ };
157
+ },
158
+ );
159
+ }
package/src/server.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerEnvironmentTools } from "./tools/environment.js";
3
+ import { registerTranslationTools } from "./tools/translations.js";
4
+ import { registerSandboxWriteTools } from "./tools/sandbox-writes.js";
5
+ import { registerProjectManagementTools } from "./tools/project-management.js";
6
+ import { registerDiffTools } from "./tools/diff.js";
7
+ import { registerSnapshotTools } from "./tools/snapshots.js";
8
+ import { registerProductionTools } from "./tools/production.js";
9
+ import { registerPrompts } from "./prompts.js";
10
+
11
+ export function createServer(): McpServer {
12
+ const server = new McpServer({
13
+ name: "localization-mcp-server",
14
+ version: "1.0.0",
15
+ });
16
+
17
+ registerEnvironmentTools(server);
18
+ registerTranslationTools(server);
19
+ registerSandboxWriteTools(server);
20
+ registerProjectManagementTools(server);
21
+ registerDiffTools(server);
22
+ registerSnapshotTools(server);
23
+ registerProductionTools(server);
24
+ registerPrompts(server);
25
+
26
+ return server;
27
+ }
@@ -0,0 +1,225 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { apiGet, ApiError } from "../api-client.js";
4
+
5
+ type DiffStatus = "added" | "changed" | "deleted";
6
+
7
+ interface DiffEntry {
8
+ namespace: string;
9
+ key: string;
10
+ locale: string;
11
+ status: DiffStatus;
12
+ productionValue: string | null;
13
+ sandboxValue: string | null;
14
+ }
15
+
16
+ interface DiffResponse {
17
+ total: number;
18
+ added: number;
19
+ changed: number;
20
+ deleted: number;
21
+ entries: DiffEntry[];
22
+ }
23
+
24
+ export function registerDiffTools(server: McpServer): void {
25
+ server.tool(
26
+ "get_translation_diff",
27
+ "Get the full diff between sandbox and production for a project. Shows all added, changed, and deleted entries.",
28
+ {
29
+ projectSlug: z.string().describe("Project slug"),
30
+ namespace: z.string().optional().describe("Filter diff to a specific namespace (optional)"),
31
+ locale: z.string().optional().describe("Filter diff to a specific locale (optional)"),
32
+ statusFilter: z
33
+ .enum(["added", "changed", "deleted", "all"])
34
+ .default("all")
35
+ .describe("Filter by change type (default: all)"),
36
+ },
37
+ async ({ projectSlug, namespace, locale, statusFilter }) => {
38
+ try {
39
+ const diff = await apiGet<DiffResponse>(
40
+ `/translations/projects/${projectSlug}/sandbox/diff`,
41
+ );
42
+
43
+ let entries = diff.entries;
44
+ if (namespace) entries = entries.filter((e) => e.namespace === namespace);
45
+ if (locale) entries = entries.filter((e) => e.locale === locale);
46
+ if (statusFilter !== "all") entries = entries.filter((e) => e.status === statusFilter);
47
+
48
+ const summary = `Diff for ${projectSlug}: ${diff.total} total changes — ${diff.added} added, ${diff.changed} changed, ${diff.deleted} deleted`;
49
+
50
+ if (entries.length === 0) {
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text" as const,
55
+ text: `${summary}\n\nNo entries match the current filters.`,
56
+ },
57
+ ],
58
+ };
59
+ }
60
+
61
+ const grouped = groupByNamespace(entries);
62
+ const sections = Object.entries(grouped).map(([ns, nsEntries]) => {
63
+ const lines = nsEntries.map((e) => formatDiffEntry(e));
64
+ return `[${ns}]\n${lines.join("\n")}`;
65
+ });
66
+
67
+ const filterNote =
68
+ namespace || locale || statusFilter !== "all"
69
+ ? `\nFilters: ${[namespace && `namespace=${namespace}`, locale && `locale=${locale}`, statusFilter !== "all" && `status=${statusFilter}`].filter(Boolean).join(", ")}`
70
+ : "";
71
+
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text" as const,
76
+ text: `${summary}${filterNote}\n\n${sections.join("\n\n")}`,
77
+ },
78
+ ],
79
+ };
80
+ } catch (error) {
81
+ return errorContent(error);
82
+ }
83
+ },
84
+ );
85
+
86
+ server.tool(
87
+ "validate_translations",
88
+ "Analyze the sandbox diff for potential issues: empty values, locales missing translations that others have, and keys only partially translated.",
89
+ {
90
+ projectSlug: z.string().describe("Project slug"),
91
+ namespace: z.string().optional().describe("Limit validation to a specific namespace (optional)"),
92
+ },
93
+ async ({ projectSlug, namespace }) => {
94
+ try {
95
+ const diff = await apiGet<DiffResponse>(
96
+ `/translations/projects/${projectSlug}/sandbox/diff`,
97
+ );
98
+
99
+ let entries = diff.entries;
100
+ if (namespace) entries = entries.filter((e) => e.namespace === namespace);
101
+
102
+ // Only check entries that will exist in production after push (not deleted)
103
+ const activeEntries = entries.filter((e) => e.status !== "deleted");
104
+
105
+ const issues: string[] = [];
106
+
107
+ // 1. Check for empty sandbox values in added/changed entries
108
+ const emptyValues = activeEntries.filter(
109
+ (e) => e.sandboxValue === null || e.sandboxValue.trim() === "",
110
+ );
111
+ if (emptyValues.length > 0) {
112
+ issues.push(
113
+ `Empty translation values (${emptyValues.length} entries):\n` +
114
+ emptyValues
115
+ .slice(0, 10)
116
+ .map((e) => ` • ${e.namespace}/${e.key} [${e.locale}]`)
117
+ .join("\n") +
118
+ (emptyValues.length > 10 ? `\n ... and ${emptyValues.length - 10} more` : ""),
119
+ );
120
+ }
121
+
122
+ // 2. Check for keys that are only partially translated across locales
123
+ const keyLocaleMap = new Map<string, Set<string>>();
124
+ for (const entry of activeEntries) {
125
+ const k = `${entry.namespace}/${entry.key}`;
126
+ if (!keyLocaleMap.has(k)) keyLocaleMap.set(k, new Set());
127
+ keyLocaleMap.get(k)!.add(entry.locale);
128
+ }
129
+
130
+ const allLocales = new Set(activeEntries.map((e) => e.locale));
131
+ const partialKeys: string[] = [];
132
+
133
+ for (const [key, locales] of keyLocaleMap.entries()) {
134
+ const missing = [...allLocales].filter((l) => !locales.has(l));
135
+ if (missing.length > 0 && missing.length < allLocales.size) {
136
+ partialKeys.push(` • ${key} — missing locales: ${missing.join(", ")}`);
137
+ }
138
+ }
139
+
140
+ if (partialKeys.length > 0) {
141
+ issues.push(
142
+ `Partially translated keys in diff (${partialKeys.length}):\n` +
143
+ partialKeys.slice(0, 10).join("\n") +
144
+ (partialKeys.length > 10 ? `\n ... and ${partialKeys.length - 10} more` : ""),
145
+ );
146
+ }
147
+
148
+ // 3. Check deleted entries — flag keys being deleted for all locales
149
+ const deletedKeys = new Map<string, number>();
150
+ for (const e of entries.filter((e) => e.status === "deleted")) {
151
+ const k = `${e.namespace}/${e.key}`;
152
+ deletedKeys.set(k, (deletedKeys.get(k) ?? 0) + 1);
153
+ }
154
+
155
+ if (deletedKeys.size > 0) {
156
+ issues.push(
157
+ `Keys being deleted from production (${deletedKeys.size}):\n` +
158
+ [...deletedKeys.entries()]
159
+ .slice(0, 10)
160
+ .map(([k, count]) => ` • ${k} (${count} locale values)`)
161
+ .join("\n") +
162
+ (deletedKeys.size > 10 ? `\n ... and ${deletedKeys.size - 10} more` : ""),
163
+ );
164
+ }
165
+
166
+ const header = `Validation for ${projectSlug}${namespace ? `/${namespace}` : ""} (${diff.total} pending changes)`;
167
+
168
+ if (issues.length === 0) {
169
+ return {
170
+ content: [
171
+ {
172
+ type: "text" as const,
173
+ text: `${header}\n\nNo issues found. All pending translations look complete.`,
174
+ },
175
+ ],
176
+ };
177
+ }
178
+
179
+ return {
180
+ content: [
181
+ {
182
+ type: "text" as const,
183
+ text: `${header}\n\nFound ${issues.length} issue(s):\n\n${issues.join("\n\n")}`,
184
+ },
185
+ ],
186
+ };
187
+ } catch (error) {
188
+ return errorContent(error);
189
+ }
190
+ },
191
+ );
192
+ }
193
+
194
+ function groupByNamespace(entries: DiffEntry[]): Record<string, DiffEntry[]> {
195
+ const result: Record<string, DiffEntry[]> = {};
196
+ for (const entry of entries) {
197
+ if (!result[entry.namespace]) result[entry.namespace] = [];
198
+ result[entry.namespace].push(entry);
199
+ }
200
+ return result;
201
+ }
202
+
203
+ function formatDiffEntry(e: DiffEntry): string {
204
+ const statusSymbol = e.status === "added" ? "+" : e.status === "deleted" ? "-" : "~";
205
+ const label = `${statusSymbol} ${e.key} [${e.locale}]`;
206
+
207
+ if (e.status === "added") {
208
+ return ` ${label}\n → "${e.sandboxValue}"`;
209
+ }
210
+ if (e.status === "deleted") {
211
+ return ` ${label}\n was: "${e.productionValue}"`;
212
+ }
213
+ return ` ${label}\n before: "${e.productionValue}"\n after: "${e.sandboxValue}"`;
214
+ }
215
+
216
+ function errorContent(error: unknown): { content: { type: "text"; text: string }[] } {
217
+ if (error instanceof ApiError) {
218
+ return {
219
+ content: [{ type: "text" as const, text: `Error ${error.status}: ${error.message}` }],
220
+ };
221
+ }
222
+ return {
223
+ content: [{ type: "text" as const, text: `Unexpected error: ${String(error)}` }],
224
+ };
225
+ }