locaflow-mcp-server 0.1.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 +132 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +611 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @locaflow/mcp-server
|
|
2
|
+
|
|
3
|
+
Translate iOS, Android & web localization files directly from [Claude Code](https://claude.com/claude-code). Powered by [LocaFlow](https://locaflow.dev).
|
|
4
|
+
|
|
5
|
+
## Quick setup (2 minutes)
|
|
6
|
+
|
|
7
|
+
### Step 1: Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @locaflow/mcp-server
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Step 2: Add to Claude Code
|
|
14
|
+
|
|
15
|
+
Open your Claude Code settings and add the MCP server:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
claude mcp add locaflow -- locaflow-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or manually add to `~/.claude/settings.json`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"locaflow": {
|
|
27
|
+
"command": "locaflow-mcp"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Step 3: Use it
|
|
34
|
+
|
|
35
|
+
Open Claude Code in any project and say:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
"Scan this project for localization files"
|
|
39
|
+
"Translate my Localizable.strings to Spanish and Japanese"
|
|
40
|
+
"Translate my App Store description to French, German, and Korean"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That's it. No API key needed — works in free demo mode (50 strings/day). For unlimited translations, set `LOCAFLOW_TOKEN` in the env config.
|
|
44
|
+
|
|
45
|
+
## Tools
|
|
46
|
+
|
|
47
|
+
### `scan_project`
|
|
48
|
+
|
|
49
|
+
Find all localization files in a project directory. Reports file paths, formats, and string counts.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
"What localization files does this project have?"
|
|
53
|
+
"Scan my project for iOS localization files"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `translate_file`
|
|
57
|
+
|
|
58
|
+
Translate a `.strings`, `strings.xml`, or `.json` file. Writes output in the correct platform convention:
|
|
59
|
+
|
|
60
|
+
- **iOS:** `es.lproj/Localizable.strings`
|
|
61
|
+
- **Android:** `values-es/strings.xml`
|
|
62
|
+
- **Web:** `es.json`
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
"Translate my Localizable.strings to Spanish and Japanese"
|
|
66
|
+
"Translate strings.xml to French, German, and Korean"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `translate_text`
|
|
70
|
+
|
|
71
|
+
Translate any text. Good for release notes, UI copy, quick checks.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
"Translate 'Welcome back!' to Korean"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `translate_appstore_metadata`
|
|
78
|
+
|
|
79
|
+
Translate App Store metadata (name, subtitle, description, keywords, what's new).
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
"Translate my app description and keywords to Spanish and Japanese"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `appstore_fetch_metadata`
|
|
86
|
+
|
|
87
|
+
Pull current metadata from App Store Connect. Returns version ID needed for pushing.
|
|
88
|
+
|
|
89
|
+
### `appstore_push_metadata`
|
|
90
|
+
|
|
91
|
+
Push translated metadata to App Store Connect.
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
"Fetch my App Store metadata, translate it to 5 languages, and push it back"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Supported formats
|
|
98
|
+
|
|
99
|
+
| Platform | Translatable files | Output convention |
|
|
100
|
+
|----------|-------------------|-------------------|
|
|
101
|
+
| iOS | `.strings` | `{lang}.lproj/` |
|
|
102
|
+
| Android | `strings.xml` | `values-{lang}/` |
|
|
103
|
+
| Web | `.json` | `{lang}.json` |
|
|
104
|
+
|
|
105
|
+
## Supported languages
|
|
106
|
+
|
|
107
|
+
30+ languages including: `es`, `fr`, `de`, `ja`, `ko`, `zh-Hans`, `zh-Hant`, `pt-BR`, `ar`, `hi`, `ru`, `tr`, `nl`, `sv`, `da`, `fi`, `nb`, `pl`, `cs`, `el`, `he`, `th`, `vi`, `id`, `ms`, `uk`, `ro`, `hu`, and more.
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
| Environment variable | Description |
|
|
112
|
+
|---------------------|-------------|
|
|
113
|
+
| `LOCAFLOW_TOKEN` | Optional. Your LocaFlow auth token for unlimited translations. Without it, demo mode (50 strings/day). |
|
|
114
|
+
|
|
115
|
+
To set the token:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"mcpServers": {
|
|
120
|
+
"locaflow": {
|
|
121
|
+
"command": "locaflow-mcp",
|
|
122
|
+
"env": {
|
|
123
|
+
"LOCAFLOW_TOKEN": "your-token-here"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LocaFlow MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Gives Claude Code the ability to translate iOS / Android / web
|
|
6
|
+
* localization files and manage App Store Connect metadata — all
|
|
7
|
+
* from the terminal.
|
|
8
|
+
*
|
|
9
|
+
* Setup — add to .claude/settings.json:
|
|
10
|
+
* "mcpServers": { "locaflow": { "command": "npx", "args": ["-y", "@locaflow/mcp-server"] } }
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LocaFlow MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Gives Claude Code the ability to translate iOS / Android / web
|
|
6
|
+
* localization files and manage App Store Connect metadata — all
|
|
7
|
+
* from the terminal.
|
|
8
|
+
*
|
|
9
|
+
* Setup — add to .claude/settings.json:
|
|
10
|
+
* "mcpServers": { "locaflow": { "command": "npx", "args": ["-y", "@locaflow/mcp-server"] } }
|
|
11
|
+
*/
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { readFile, writeFile, readdir, stat, mkdir, access } from "node:fs/promises";
|
|
16
|
+
import { join, extname, basename, dirname, relative } from "node:path";
|
|
17
|
+
import { constants } from "node:fs";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Config
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const SUPABASE_URL = "https://mtyoymatixwafhjfdoja.supabase.co";
|
|
22
|
+
const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10eW95bWF0aXh3YWZoamZkb2phIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg3NTY3MDYsImV4cCI6MjA2NDMzMjcwNn0.d1R6HFKzqBHvqVcZwEjAZYvrFqAAHp5kxHpvNTqNjCA";
|
|
23
|
+
const TRANSLATE_FN = `${SUPABASE_URL}/functions/v1/translate`;
|
|
24
|
+
const ASC_FN = `${SUPABASE_URL}/functions/v1/appstore-connect`;
|
|
25
|
+
const MAX_BATCH_AUTH = 20;
|
|
26
|
+
const MAX_BATCH_DEMO = 10;
|
|
27
|
+
function getToken() {
|
|
28
|
+
return process.env.LOCAFLOW_TOKEN;
|
|
29
|
+
}
|
|
30
|
+
/** Return a structured MCP error content block */
|
|
31
|
+
function errorResult(message) {
|
|
32
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/** Call the LocaFlow translate edge function */
|
|
38
|
+
async function translateBatch(texts, targetLanguage, opts = {}) {
|
|
39
|
+
const isDemo = !opts.token;
|
|
40
|
+
const headers = {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
43
|
+
"Authorization": `Bearer ${opts.token || SUPABASE_ANON_KEY}`,
|
|
44
|
+
};
|
|
45
|
+
const body = {
|
|
46
|
+
texts,
|
|
47
|
+
targetLanguage,
|
|
48
|
+
...(opts.targetLanguageName && { targetLanguageName: opts.targetLanguageName }),
|
|
49
|
+
...(opts.context && { context: opts.context }),
|
|
50
|
+
...(opts.preserveFormatting !== undefined && { preserveFormatting: opts.preserveFormatting }),
|
|
51
|
+
...(opts.creativityLevel !== undefined && { creativityLevel: opts.creativityLevel }),
|
|
52
|
+
...(isDemo && { isDemo: true }),
|
|
53
|
+
};
|
|
54
|
+
let res;
|
|
55
|
+
try {
|
|
56
|
+
res = await fetch(TRANSLATE_FN, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers,
|
|
59
|
+
body: JSON.stringify(body),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new Error(`Network error calling LocaFlow API: ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
let detail;
|
|
67
|
+
try {
|
|
68
|
+
const err = (await res.json());
|
|
69
|
+
detail = err.error || err.message || res.statusText;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
detail = `HTTP ${res.status} ${res.statusText}`;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`LocaFlow API error (${res.status}): ${detail}`);
|
|
75
|
+
}
|
|
76
|
+
const data = (await res.json());
|
|
77
|
+
return data.translatedTexts ?? [data.translatedText];
|
|
78
|
+
}
|
|
79
|
+
/** Parse a .strings file into key-value pairs */
|
|
80
|
+
function parseStringsFile(content) {
|
|
81
|
+
const entries = [];
|
|
82
|
+
const lines = content.split("\n");
|
|
83
|
+
let currentComment;
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
if (trimmed.startsWith("/*") && trimmed.endsWith("*/")) {
|
|
87
|
+
currentComment = trimmed.slice(2, -2).trim();
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const match = trimmed.match(/^"(.+?)"\s*=\s*"(.+?)"\s*;$/);
|
|
91
|
+
if (match) {
|
|
92
|
+
entries.push({ key: match[1], value: match[2], comment: currentComment });
|
|
93
|
+
currentComment = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
/** Rebuild a .strings file from entries */
|
|
99
|
+
function buildStringsFile(entries) {
|
|
100
|
+
return entries
|
|
101
|
+
.map((e) => {
|
|
102
|
+
const commentLine = e.comment ? `/* ${e.comment} */\n` : "";
|
|
103
|
+
return `${commentLine}"${e.key}" = "${e.value}";`;
|
|
104
|
+
})
|
|
105
|
+
.join("\n\n") + "\n";
|
|
106
|
+
}
|
|
107
|
+
/** Parse Android strings.xml */
|
|
108
|
+
function parseAndroidXml(content) {
|
|
109
|
+
const entries = [];
|
|
110
|
+
const regex = /<string\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/string>/g;
|
|
111
|
+
let match;
|
|
112
|
+
while ((match = regex.exec(content)) !== null) {
|
|
113
|
+
entries.push({
|
|
114
|
+
key: match[1],
|
|
115
|
+
value: match[2]
|
|
116
|
+
.replace(/\\'/g, "'")
|
|
117
|
+
.replace(/&/g, "&")
|
|
118
|
+
.replace(/</g, "<")
|
|
119
|
+
.replace(/>/g, ">"),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return entries;
|
|
123
|
+
}
|
|
124
|
+
/** Rebuild Android strings.xml */
|
|
125
|
+
function buildAndroidXml(entries) {
|
|
126
|
+
const lines = entries.map((e) => ` <string name="${e.key}">${e.value
|
|
127
|
+
.replace(/&/g, "&")
|
|
128
|
+
.replace(/</g, "<")
|
|
129
|
+
.replace(/>/g, ">")
|
|
130
|
+
.replace(/'/g, "\\'")}</string>`);
|
|
131
|
+
return `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n${lines.join("\n")}\n</resources>\n`;
|
|
132
|
+
}
|
|
133
|
+
/** Parse JSON localization file (supports nested keys) */
|
|
134
|
+
function parseJsonFile(content) {
|
|
135
|
+
const obj = JSON.parse(content);
|
|
136
|
+
const entries = [];
|
|
137
|
+
function walk(o, prefix) {
|
|
138
|
+
for (const [k, v] of Object.entries(o)) {
|
|
139
|
+
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
140
|
+
if (typeof v === "string") {
|
|
141
|
+
entries.push({ key: fullKey, value: v });
|
|
142
|
+
}
|
|
143
|
+
else if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
144
|
+
walk(v, fullKey);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
walk(obj, "");
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
151
|
+
/** Rebuild nested JSON from flat key-value pairs */
|
|
152
|
+
function buildJsonFile(entries) {
|
|
153
|
+
const obj = {};
|
|
154
|
+
for (const { key, value } of entries) {
|
|
155
|
+
const parts = key.split(".");
|
|
156
|
+
let cur = obj;
|
|
157
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
158
|
+
if (!(parts[i] in cur))
|
|
159
|
+
cur[parts[i]] = {};
|
|
160
|
+
cur = cur[parts[i]];
|
|
161
|
+
}
|
|
162
|
+
cur[parts[parts.length - 1]] = value;
|
|
163
|
+
}
|
|
164
|
+
return JSON.stringify(obj, null, 2) + "\n";
|
|
165
|
+
}
|
|
166
|
+
/** Detect file type and parse accordingly */
|
|
167
|
+
function parseLocFile(filePath, content) {
|
|
168
|
+
const ext = extname(filePath).toLowerCase();
|
|
169
|
+
if (ext === ".strings")
|
|
170
|
+
return { format: "strings", entries: parseStringsFile(content) };
|
|
171
|
+
if (ext === ".xml")
|
|
172
|
+
return { format: "android", entries: parseAndroidXml(content) };
|
|
173
|
+
if (ext === ".json")
|
|
174
|
+
return { format: "json", entries: parseJsonFile(content) };
|
|
175
|
+
throw new Error(`Unsupported file format: ${ext}. Supported: .strings, .xml (strings.xml), .json`);
|
|
176
|
+
}
|
|
177
|
+
/** Rebuild file from entries */
|
|
178
|
+
function buildLocFile(format, entries) {
|
|
179
|
+
if (format === "strings")
|
|
180
|
+
return buildStringsFile(entries);
|
|
181
|
+
if (format === "android")
|
|
182
|
+
return buildAndroidXml(entries);
|
|
183
|
+
return buildJsonFile(entries);
|
|
184
|
+
}
|
|
185
|
+
/** Check if a directory exists and is readable */
|
|
186
|
+
async function directoryExists(dir) {
|
|
187
|
+
try {
|
|
188
|
+
await access(dir, constants.R_OK);
|
|
189
|
+
const s = await stat(dir);
|
|
190
|
+
return s.isDirectory();
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Recursively find localization files that we can actually translate */
|
|
197
|
+
async function findLocFiles(dir, platform) {
|
|
198
|
+
const results = [];
|
|
199
|
+
// Only scan for formats we can actually parse and translate
|
|
200
|
+
const platformExts = {
|
|
201
|
+
ios: [".strings"],
|
|
202
|
+
android: [".xml"],
|
|
203
|
+
web: [".json"],
|
|
204
|
+
};
|
|
205
|
+
const validExts = platform
|
|
206
|
+
? platformExts[platform] || []
|
|
207
|
+
: [".strings", ".xml", ".json"];
|
|
208
|
+
async function walk(d, depth) {
|
|
209
|
+
if (depth > 6)
|
|
210
|
+
return;
|
|
211
|
+
const items = await readdir(d).catch(() => []);
|
|
212
|
+
for (const item of items) {
|
|
213
|
+
if (item.startsWith(".") ||
|
|
214
|
+
item === "node_modules" ||
|
|
215
|
+
item === "Pods" ||
|
|
216
|
+
item === "build" ||
|
|
217
|
+
item === "dist" ||
|
|
218
|
+
item === "DerivedData")
|
|
219
|
+
continue;
|
|
220
|
+
const full = join(d, item);
|
|
221
|
+
const s = await stat(full).catch(() => null);
|
|
222
|
+
if (!s)
|
|
223
|
+
continue;
|
|
224
|
+
if (s.isDirectory()) {
|
|
225
|
+
await walk(full, depth + 1);
|
|
226
|
+
}
|
|
227
|
+
else if (validExts.some((ext) => item.endsWith(ext))) {
|
|
228
|
+
// For android, only match strings.xml (not any .xml)
|
|
229
|
+
if (platform === "android" && item !== "strings.xml")
|
|
230
|
+
continue;
|
|
231
|
+
// For web, skip package.json, tsconfig.json etc
|
|
232
|
+
if (item.endsWith(".json") && !item.match(/^[a-z]{2}(-[A-Za-z]+)?\.json$/) && item !== "messages.json" && item !== "translations.json" && item !== "locale.json") {
|
|
233
|
+
// Heuristic: only pick .json files that look like locale files
|
|
234
|
+
// (2-letter code, or explicit localization names)
|
|
235
|
+
// But also accept any .json if the user explicitly chose "web" platform
|
|
236
|
+
if (!platform)
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
results.push(full);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await walk(dir, 0);
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Language mapping
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
const LANGUAGE_NAMES = {
|
|
250
|
+
es: "Spanish", fr: "French", de: "German", it: "Italian", pt: "Portuguese",
|
|
251
|
+
"pt-BR": "Brazilian Portuguese", ja: "Japanese", ko: "Korean",
|
|
252
|
+
"zh-Hans": "Simplified Chinese", "zh-Hant": "Traditional Chinese",
|
|
253
|
+
ar: "Arabic", hi: "Hindi", ru: "Russian", tr: "Turkish", nl: "Dutch",
|
|
254
|
+
sv: "Swedish", da: "Danish", fi: "Finnish", nb: "Norwegian", pl: "Polish",
|
|
255
|
+
cs: "Czech", el: "Greek", he: "Hebrew", th: "Thai", vi: "Vietnamese",
|
|
256
|
+
id: "Indonesian", ms: "Malay", uk: "Ukrainian", ro: "Romanian",
|
|
257
|
+
hu: "Hungarian", sk: "Slovak", bg: "Bulgarian", hr: "Croatian",
|
|
258
|
+
ca: "Catalan", eu: "Basque",
|
|
259
|
+
};
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// MCP Server
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
const server = new McpServer({
|
|
264
|
+
name: "locaflow",
|
|
265
|
+
version: "0.1.0",
|
|
266
|
+
});
|
|
267
|
+
// ---- Tool: translate_file ----
|
|
268
|
+
server.tool("translate_file", "Translate an iOS .strings, Android strings.xml, or web .json localization file to one or more languages. Writes translated files alongside the source.", {
|
|
269
|
+
file_path: z.string().describe("Absolute path to the source localization file (.strings, .xml, or .json)"),
|
|
270
|
+
target_languages: z.array(z.string()).describe('ISO 639-1 language codes to translate to, e.g. ["es", "ja", "de"]'),
|
|
271
|
+
output_dir: z.string().optional().describe("Directory to write translated files. Defaults to same directory as source file."),
|
|
272
|
+
context: z.string().optional().describe("Extra context for the translator, e.g. 'casual fitness app for teens'"),
|
|
273
|
+
creativity_level: z.number().min(1).max(5).optional().describe("Translation creativity 1-5 (1=literal, 5=creative). Default 3."),
|
|
274
|
+
}, async ({ file_path, target_languages, output_dir, context, creativity_level }) => {
|
|
275
|
+
const token = getToken();
|
|
276
|
+
// Read & parse source file
|
|
277
|
+
let content;
|
|
278
|
+
try {
|
|
279
|
+
content = await readFile(file_path, "utf-8");
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
return errorResult(`Cannot read file: ${file_path}\n${err.message}`);
|
|
283
|
+
}
|
|
284
|
+
let format;
|
|
285
|
+
let entries;
|
|
286
|
+
try {
|
|
287
|
+
const parsed = parseLocFile(file_path, content);
|
|
288
|
+
format = parsed.format;
|
|
289
|
+
entries = parsed.entries;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return errorResult(err.message);
|
|
293
|
+
}
|
|
294
|
+
if (entries.length === 0) {
|
|
295
|
+
return errorResult(`No translatable strings found in ${file_path}. The file may be empty or in an unsupported structure.`);
|
|
296
|
+
}
|
|
297
|
+
const outDir = output_dir || dirname(file_path);
|
|
298
|
+
const sourceBasename = basename(file_path);
|
|
299
|
+
const results = [];
|
|
300
|
+
const errors = [];
|
|
301
|
+
for (const lang of target_languages) {
|
|
302
|
+
const langName = LANGUAGE_NAMES[lang] || lang;
|
|
303
|
+
try {
|
|
304
|
+
// Batch translate all values (demo mode has smaller batch limit)
|
|
305
|
+
const batchSize = token ? MAX_BATCH_AUTH : MAX_BATCH_DEMO;
|
|
306
|
+
const values = entries.map((e) => e.value);
|
|
307
|
+
const translated = [];
|
|
308
|
+
for (let i = 0; i < values.length; i += batchSize) {
|
|
309
|
+
// Demo mode has a 3-second burst protection between requests
|
|
310
|
+
if (!token && i > 0)
|
|
311
|
+
await new Promise((r) => setTimeout(r, 3200));
|
|
312
|
+
const batch = values.slice(i, i + batchSize);
|
|
313
|
+
const result = await translateBatch(batch, lang, {
|
|
314
|
+
targetLanguageName: langName,
|
|
315
|
+
context,
|
|
316
|
+
preserveFormatting: true,
|
|
317
|
+
creativityLevel: creativity_level,
|
|
318
|
+
token,
|
|
319
|
+
});
|
|
320
|
+
translated.push(...result);
|
|
321
|
+
}
|
|
322
|
+
// Build translated entries
|
|
323
|
+
const translatedEntries = entries.map((e, i) => ({
|
|
324
|
+
...e,
|
|
325
|
+
value: translated[i] || e.value,
|
|
326
|
+
}));
|
|
327
|
+
// Determine output path
|
|
328
|
+
let outPath;
|
|
329
|
+
if (format === "strings") {
|
|
330
|
+
const lprojDir = join(outDir, `${lang}.lproj`);
|
|
331
|
+
await mkdir(lprojDir, { recursive: true });
|
|
332
|
+
outPath = join(lprojDir, sourceBasename);
|
|
333
|
+
}
|
|
334
|
+
else if (format === "android") {
|
|
335
|
+
const valuesDir = join(outDir, `values-${lang}`);
|
|
336
|
+
await mkdir(valuesDir, { recursive: true });
|
|
337
|
+
outPath = join(valuesDir, "strings.xml");
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
outPath = join(outDir, `${lang}.json`);
|
|
341
|
+
}
|
|
342
|
+
const translatedContent = buildLocFile(format, translatedEntries);
|
|
343
|
+
await writeFile(outPath, translatedContent, "utf-8");
|
|
344
|
+
results.push(` ${lang} (${langName}): ${outPath} — ${translatedEntries.length} strings`);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
errors.push(` ${lang} (${langName}): FAILED — ${err.message}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const lines = [
|
|
351
|
+
`Source: ${file_path} (${entries.length} strings, ${format})`,
|
|
352
|
+
];
|
|
353
|
+
if (results.length > 0) {
|
|
354
|
+
lines.push(`\nTranslated (${results.length}/${target_languages.length} languages):`);
|
|
355
|
+
lines.push(...results);
|
|
356
|
+
}
|
|
357
|
+
if (errors.length > 0) {
|
|
358
|
+
lines.push(`\nFailed:`);
|
|
359
|
+
lines.push(...errors);
|
|
360
|
+
}
|
|
361
|
+
if (errors.length > 0 && results.length === 0) {
|
|
362
|
+
return errorResult(lines.join("\n"));
|
|
363
|
+
}
|
|
364
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
365
|
+
});
|
|
366
|
+
// ---- Tool: translate_text ----
|
|
367
|
+
server.tool("translate_text", "Translate arbitrary text to a target language via LocaFlow. Good for App Store descriptions, release notes, or any string.", {
|
|
368
|
+
text: z.string().describe("The text to translate"),
|
|
369
|
+
target_language: z.string().describe('ISO 639-1 language code, e.g. "es", "ja", "zh-Hans"'),
|
|
370
|
+
context: z.string().optional().describe("Context for better translation quality"),
|
|
371
|
+
}, async ({ text, target_language, context }) => {
|
|
372
|
+
const token = getToken();
|
|
373
|
+
const langName = LANGUAGE_NAMES[target_language] || target_language;
|
|
374
|
+
try {
|
|
375
|
+
const [translated] = await translateBatch([text], target_language, {
|
|
376
|
+
targetLanguageName: langName,
|
|
377
|
+
context,
|
|
378
|
+
preserveFormatting: true,
|
|
379
|
+
token,
|
|
380
|
+
});
|
|
381
|
+
return {
|
|
382
|
+
content: [{ type: "text", text: `**${langName} (${target_language}):**\n${translated}` }],
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
return errorResult(`Translation failed: ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
// ---- Tool: scan_project ----
|
|
390
|
+
server.tool("scan_project", "Scan a project directory for localization files (.strings, strings.xml, .json). Returns file paths, formats, and string counts.", {
|
|
391
|
+
directory: z.string().describe("Absolute path to the project root"),
|
|
392
|
+
platform: z.enum(["ios", "android", "web"]).optional().describe("Filter by platform"),
|
|
393
|
+
}, async ({ directory, platform }) => {
|
|
394
|
+
// Validate directory exists
|
|
395
|
+
const exists = await directoryExists(directory);
|
|
396
|
+
if (!exists) {
|
|
397
|
+
return errorResult(`Directory not found: ${directory}\nMake sure the path exists and is readable.`);
|
|
398
|
+
}
|
|
399
|
+
const files = await findLocFiles(directory, platform);
|
|
400
|
+
if (files.length === 0) {
|
|
401
|
+
const platformHint = platform
|
|
402
|
+
? `\nLooked for: ${platform === "ios" ? ".strings" : platform === "android" ? "strings.xml" : ".json"} files`
|
|
403
|
+
: "";
|
|
404
|
+
return {
|
|
405
|
+
content: [{
|
|
406
|
+
type: "text",
|
|
407
|
+
text: `No localization files found in ${directory}${platformHint}\n\nTip: try specifying a platform (ios, android, web) or check that the directory contains localization files.`,
|
|
408
|
+
}],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const details = [];
|
|
412
|
+
let totalStrings = 0;
|
|
413
|
+
for (const f of files) {
|
|
414
|
+
try {
|
|
415
|
+
const content = await readFile(f, "utf-8");
|
|
416
|
+
const { format, entries } = parseLocFile(f, content);
|
|
417
|
+
totalStrings += entries.length;
|
|
418
|
+
details.push(` ${relative(directory, f)} [${format}] ${entries.length} strings`);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
details.push(` ${relative(directory, f)} [error reading]`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const summary = [
|
|
425
|
+
`Found ${files.length} localization file(s) in ${directory}`,
|
|
426
|
+
`Total translatable strings: ${totalStrings}`,
|
|
427
|
+
``,
|
|
428
|
+
...details,
|
|
429
|
+
].join("\n");
|
|
430
|
+
return { content: [{ type: "text", text: summary }] };
|
|
431
|
+
});
|
|
432
|
+
// ---- Tool: translate_appstore_metadata ----
|
|
433
|
+
server.tool("translate_appstore_metadata", "Translate App Store metadata (name, subtitle, description, keywords, what's new) to multiple languages. Returns the translated metadata as structured text you can paste or push via appstore_push_metadata.", {
|
|
434
|
+
name: z.string().optional().describe("App name (max 30 chars)"),
|
|
435
|
+
subtitle: z.string().optional().describe("App subtitle (max 30 chars)"),
|
|
436
|
+
description: z.string().optional().describe("App description"),
|
|
437
|
+
keywords: z.string().optional().describe("Comma-separated keywords (max 100 chars total)"),
|
|
438
|
+
whats_new: z.string().optional().describe("What's New / release notes"),
|
|
439
|
+
target_languages: z.array(z.string()).describe('ISO 639-1 codes, e.g. ["es", "ja", "de"]'),
|
|
440
|
+
context: z.string().optional().describe("App context for better translation, e.g. 'meditation app for beginners'"),
|
|
441
|
+
}, async ({ name, subtitle, description, keywords, whats_new, target_languages, context }) => {
|
|
442
|
+
const token = getToken();
|
|
443
|
+
const fields = [
|
|
444
|
+
{ label: "name", value: name },
|
|
445
|
+
{ label: "subtitle", value: subtitle },
|
|
446
|
+
{ label: "description", value: description },
|
|
447
|
+
{ label: "keywords", value: keywords },
|
|
448
|
+
{ label: "whats_new", value: whats_new },
|
|
449
|
+
].filter((f) => f.value);
|
|
450
|
+
if (fields.length === 0) {
|
|
451
|
+
return errorResult("No metadata fields provided. Pass at least one of: name, subtitle, description, keywords, whats_new.");
|
|
452
|
+
}
|
|
453
|
+
const results = [];
|
|
454
|
+
const errors = [];
|
|
455
|
+
for (const lang of target_languages) {
|
|
456
|
+
const langName = LANGUAGE_NAMES[lang] || lang;
|
|
457
|
+
try {
|
|
458
|
+
const texts = fields.map((f) => f.value);
|
|
459
|
+
const translated = await translateBatch(texts, lang, {
|
|
460
|
+
targetLanguageName: langName,
|
|
461
|
+
context: context || "App Store metadata — keep translations concise and within character limits",
|
|
462
|
+
preserveFormatting: true,
|
|
463
|
+
token,
|
|
464
|
+
});
|
|
465
|
+
results.push(`\n## ${langName} (${lang})`);
|
|
466
|
+
fields.forEach((f, i) => {
|
|
467
|
+
results.push(`**${f.label}:** ${translated[i]}`);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
errors.push(`${langName} (${lang}): ${err.message}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
let output = `# App Store Metadata Translations\n${results.join("\n")}`;
|
|
475
|
+
if (errors.length > 0) {
|
|
476
|
+
output += `\n\n## Errors\n${errors.join("\n")}`;
|
|
477
|
+
}
|
|
478
|
+
return { content: [{ type: "text", text: output }] };
|
|
479
|
+
});
|
|
480
|
+
// ---- Tool: appstore_fetch_metadata ----
|
|
481
|
+
server.tool("appstore_fetch_metadata", "Fetch current App Store Connect metadata for an app. Requires ASC API credentials. Returns version_id needed for appstore_push_metadata.", {
|
|
482
|
+
issuer_id: z.string().describe("App Store Connect API issuer ID"),
|
|
483
|
+
key_id: z.string().describe("App Store Connect API key ID"),
|
|
484
|
+
private_key: z.string().describe("ES256 private key (PEM format)"),
|
|
485
|
+
app_id: z.string().describe("App Store Connect app ID"),
|
|
486
|
+
}, async ({ issuer_id, key_id, private_key, app_id }) => {
|
|
487
|
+
const token = getToken();
|
|
488
|
+
const headers = {
|
|
489
|
+
"Content-Type": "application/json",
|
|
490
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
491
|
+
"Authorization": `Bearer ${token || SUPABASE_ANON_KEY}`,
|
|
492
|
+
};
|
|
493
|
+
let data;
|
|
494
|
+
try {
|
|
495
|
+
const res = await fetch(ASC_FN, {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers,
|
|
498
|
+
body: JSON.stringify({
|
|
499
|
+
action: "fetch-metadata",
|
|
500
|
+
issuerId: issuer_id,
|
|
501
|
+
keyId: key_id,
|
|
502
|
+
privateKey: private_key,
|
|
503
|
+
appId: app_id,
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
data = await res.json();
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
return errorResult(`Failed to connect to App Store Connect API: ${err.message}`);
|
|
510
|
+
}
|
|
511
|
+
if (!data.success) {
|
|
512
|
+
return errorResult(`App Store Connect error: ${data.error || data.message || "Unknown error"}`);
|
|
513
|
+
}
|
|
514
|
+
if (!data.versionId) {
|
|
515
|
+
return errorResult(`App Store Connect returned success but no version ID. Make sure the app has an editable version.`);
|
|
516
|
+
}
|
|
517
|
+
const locales = (data.locales || []);
|
|
518
|
+
const lines = [
|
|
519
|
+
`# App Store Metadata (v${data.versionString || "unknown"})`,
|
|
520
|
+
`**Version ID:** ${data.versionId}`,
|
|
521
|
+
`**Locales:** ${locales.length}`,
|
|
522
|
+
"",
|
|
523
|
+
];
|
|
524
|
+
for (const loc of locales) {
|
|
525
|
+
lines.push(`## ${loc.locale}`);
|
|
526
|
+
if (loc.name)
|
|
527
|
+
lines.push(`**Name:** ${loc.name}`);
|
|
528
|
+
if (loc.subtitle)
|
|
529
|
+
lines.push(`**Subtitle:** ${loc.subtitle}`);
|
|
530
|
+
if (loc.description)
|
|
531
|
+
lines.push(`**Description:** ${loc.description}`);
|
|
532
|
+
if (loc.keywords)
|
|
533
|
+
lines.push(`**Keywords:** ${loc.keywords}`);
|
|
534
|
+
if (loc.whatsNew)
|
|
535
|
+
lines.push(`**What's New:** ${loc.whatsNew}`);
|
|
536
|
+
lines.push("");
|
|
537
|
+
}
|
|
538
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
539
|
+
});
|
|
540
|
+
// ---- Tool: appstore_push_metadata ----
|
|
541
|
+
server.tool("appstore_push_metadata", "Push translated metadata to App Store Connect. Requires version_id from appstore_fetch_metadata. Updates name, subtitle, description, keywords, and/or what's new for specified locales.", {
|
|
542
|
+
issuer_id: z.string().describe("App Store Connect API issuer ID"),
|
|
543
|
+
key_id: z.string().describe("App Store Connect API key ID"),
|
|
544
|
+
private_key: z.string().describe("ES256 private key (PEM format)"),
|
|
545
|
+
app_id: z.string().describe("App Store Connect app ID"),
|
|
546
|
+
version_id: z.string().describe("Version ID from appstore_fetch_metadata"),
|
|
547
|
+
locales: z
|
|
548
|
+
.array(z.object({
|
|
549
|
+
locale: z.string().describe('Locale code, e.g. "en-US", "ja", "es-MX"'),
|
|
550
|
+
name: z.string().optional(),
|
|
551
|
+
subtitle: z.string().optional(),
|
|
552
|
+
description: z.string().optional(),
|
|
553
|
+
keywords: z.string().optional(),
|
|
554
|
+
whats_new: z.string().optional(),
|
|
555
|
+
}))
|
|
556
|
+
.describe("Array of locale metadata to push"),
|
|
557
|
+
}, async ({ issuer_id, key_id, private_key, app_id, version_id, locales }) => {
|
|
558
|
+
const token = getToken();
|
|
559
|
+
const headers = {
|
|
560
|
+
"Content-Type": "application/json",
|
|
561
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
562
|
+
"Authorization": `Bearer ${token || SUPABASE_ANON_KEY}`,
|
|
563
|
+
};
|
|
564
|
+
let data;
|
|
565
|
+
try {
|
|
566
|
+
const res = await fetch(ASC_FN, {
|
|
567
|
+
method: "POST",
|
|
568
|
+
headers,
|
|
569
|
+
body: JSON.stringify({
|
|
570
|
+
action: "push-metadata",
|
|
571
|
+
issuerId: issuer_id,
|
|
572
|
+
keyId: key_id,
|
|
573
|
+
privateKey: private_key,
|
|
574
|
+
appId: app_id,
|
|
575
|
+
versionId: version_id,
|
|
576
|
+
locales: locales.map((l) => ({
|
|
577
|
+
locale: l.locale,
|
|
578
|
+
name: l.name,
|
|
579
|
+
subtitle: l.subtitle,
|
|
580
|
+
description: l.description,
|
|
581
|
+
keywords: l.keywords,
|
|
582
|
+
whatsNew: l.whats_new,
|
|
583
|
+
})),
|
|
584
|
+
}),
|
|
585
|
+
});
|
|
586
|
+
data = await res.json();
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
return errorResult(`Failed to connect to App Store Connect API: ${err.message}`);
|
|
590
|
+
}
|
|
591
|
+
if (!data.success) {
|
|
592
|
+
return errorResult(`App Store Connect error: ${data.error || data.message || "Unknown error"}`);
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
content: [{
|
|
596
|
+
type: "text",
|
|
597
|
+
text: `Successfully updated ${data.updatedLocales || data.updated || locales.length} locale(s).${data.failures?.length ? `\nFailures: ${data.failures.join(", ")}` : ""}`,
|
|
598
|
+
}],
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Start
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
async function main() {
|
|
605
|
+
const transport = new StdioServerTransport();
|
|
606
|
+
await server.connect(transport);
|
|
607
|
+
}
|
|
608
|
+
main().catch((err) => {
|
|
609
|
+
console.error("LocaFlow MCP server error:", err);
|
|
610
|
+
process.exit(1);
|
|
611
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "locaflow-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LocaFlow MCP server — translate iOS, Android & web localization files from Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"locaflow-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"start": "node dist/index.js",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"localization",
|
|
23
|
+
"translation",
|
|
24
|
+
"ios",
|
|
25
|
+
"android",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"mcp-server",
|
|
28
|
+
"i18n",
|
|
29
|
+
"l10n",
|
|
30
|
+
"app-store-connect"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/nicktarasov/locaflow"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://locaflow.dev",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.11.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.0.0",
|
|
43
|
+
"typescript": "^5.7.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|