@camscanner/mcp-language-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.
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "camscanner-plugins",
3
+ "owner": {
4
+ "name": "CamScanner"
5
+ },
6
+ "plugins": [
7
+ {
8
+ "name": "i18n",
9
+ "description": "CamScanner multi-language string integration plugin. Search, export, and write locale strings via MCP language server.",
10
+ "source": "./plugins/i18n",
11
+ "category": "development"
12
+ },
13
+ {
14
+ "name": "yapi",
15
+ "description": "YApi API documentation management plugin. Search, query, create, and export API documentation via YApi MCP server.",
16
+ "source": "./plugins/yapi",
17
+ "category": "development"
18
+ }
19
+ ]
20
+ }
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # CamScanner i18n MCP Language Server
2
+
3
+ 用于 Claude Code 的多语言字符串管理 MCP Server,支持从 CamScanner 运营平台搜索、导出、写入多语言字符串。
4
+
5
+ ## 功能
6
+
7
+ | 工具 | 说明 |
8
+ |------|------|
9
+ | `authenticate` | 浏览器登录运营平台 |
10
+ | `logout` | 退出登录,清除凭证 |
11
+ | `search-string` | 按关键词搜索多语言字符串 |
12
+ | `export-string` | 导出为 cs-i18n 兼容的 locale JSON |
13
+ | `write-locales` | 将字符串直接写入项目 locales 目录 |
14
+ | `get-version-list` | 获取产品版本列表 |
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ # 1. 添加市场(仅首次)
20
+ claude plugin marketplace add tianmuji/camscanner-plugins
21
+
22
+ # 2. 安装插件
23
+ claude plugin install i18n@camscanner-plugins
24
+ ```
25
+
26
+ 安装后重启 Claude Code 即可使用。插件会自动注册 MCP Server 和 `/i18n` Skill。
27
+
28
+ ### 前提条件
29
+
30
+ - Node.js >= 18
31
+ - Playwright Chromium(用于浏览器登录):`npx playwright install chromium`
32
+
33
+ ## 认证
34
+
35
+ 首次使用时调用 `authenticate` 工具,会打开浏览器进行 SSO 登录(扫码验证 + 密码)。
36
+
37
+ - 浏览器数据持久化在 `~/.language-mcp/browser-data/`,保存的密码下次自动填充
38
+ - 认证信息保存在 `~/.language-mcp/credentials.json`,有效期 24 小时
39
+
40
+ ## 常用产品 ID
41
+
42
+ | ID | 产品 |
43
+ |----|------|
44
+ | 1 | CamCard |
45
+ | 2 | CamScanner |
46
+ | 44 | CamScanner Lite |
47
+ | 47 | CS PDF |
48
+ | 53 | CS Harmony |
package/dist/auth.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { OperateCredentials } from './operate-client.js';
2
+ export declare function loadCredentials(): Promise<OperateCredentials | null>;
3
+ export declare function saveCredentials(creds: OperateCredentials): Promise<void>;
4
+ export declare function clearCredentials(): Promise<void>;
5
+ export interface SsoConfig {
6
+ operateBaseUrl: string;
7
+ }
8
+ /**
9
+ * Launch a browser for the user to complete the full login flow
10
+ * (SSO + zero-trust gateway), then extract all cookies and CSRF token.
11
+ */
12
+ export declare function startSsoLogin(config: SsoConfig): Promise<OperateCredentials>;
package/dist/auth.js ADDED
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadCredentials = loadCredentials;
7
+ exports.saveCredentials = saveCredentials;
8
+ exports.clearCredentials = clearCredentials;
9
+ exports.startSsoLogin = startSsoLogin;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const os_1 = __importDefault(require("os"));
13
+ const playwright_core_1 = require("playwright-core");
14
+ // --- Credentials persistence ---
15
+ const CREDS_DIR = path_1.default.join(os_1.default.homedir(), '.language-mcp');
16
+ const CREDS_FILE = path_1.default.join(CREDS_DIR, 'credentials.json');
17
+ async function loadCredentials() {
18
+ try {
19
+ if (!fs_1.default.existsSync(CREDS_FILE))
20
+ return null;
21
+ const data = JSON.parse(fs_1.default.readFileSync(CREDS_FILE, 'utf-8'));
22
+ if (data && data.expiresAt > Date.now())
23
+ return data;
24
+ return null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ async function saveCredentials(creds) {
31
+ if (!fs_1.default.existsSync(CREDS_DIR))
32
+ fs_1.default.mkdirSync(CREDS_DIR, { recursive: true });
33
+ fs_1.default.writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2));
34
+ }
35
+ async function clearCredentials() {
36
+ try {
37
+ fs_1.default.unlinkSync(CREDS_FILE);
38
+ }
39
+ catch { /* ignore */ }
40
+ }
41
+ // --- Find system Chromium installed by Playwright ---
42
+ function findChromium() {
43
+ const cacheDir = path_1.default.join(os_1.default.homedir(), 'Library', 'Caches', 'ms-playwright');
44
+ if (!fs_1.default.existsSync(cacheDir))
45
+ return undefined;
46
+ const dirs = fs_1.default.readdirSync(cacheDir)
47
+ .filter(d => d.startsWith('chromium-'))
48
+ .sort()
49
+ .reverse();
50
+ for (const dir of dirs) {
51
+ const candidates = [
52
+ path_1.default.join(cacheDir, dir, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'),
53
+ path_1.default.join(cacheDir, dir, 'chrome-mac', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'),
54
+ path_1.default.join(cacheDir, dir, 'chrome-mac-arm64', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
55
+ path_1.default.join(cacheDir, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
56
+ path_1.default.join(cacheDir, dir, 'chrome-linux', 'chrome'),
57
+ ];
58
+ for (const c of candidates) {
59
+ if (fs_1.default.existsSync(c))
60
+ return c;
61
+ }
62
+ }
63
+ return undefined;
64
+ }
65
+ /**
66
+ * Launch a browser for the user to complete the full login flow
67
+ * (SSO + zero-trust gateway), then extract all cookies and CSRF token.
68
+ */
69
+ async function startSsoLogin(config) {
70
+ const execPath = findChromium();
71
+ if (!execPath) {
72
+ throw new Error('Cannot find Chromium. Please install Playwright browsers: npx playwright install chromium');
73
+ }
74
+ // Persistent browser data dir — saves passwords, cookies across sessions
75
+ const userDataDir = path_1.default.join(CREDS_DIR, 'browser-data');
76
+ if (!fs_1.default.existsSync(userDataDir))
77
+ fs_1.default.mkdirSync(userDataDir, { recursive: true });
78
+ // Navigate to a page that requires auth — this triggers the SSO redirect chain
79
+ const targetUrl = config.operateBaseUrl + '/multilanguage/edit-language';
80
+ console.error('[Auth] Launching browser for login...');
81
+ const context = await playwright_core_1.chromium.launchPersistentContext(userDataDir, {
82
+ headless: false,
83
+ executablePath: execPath,
84
+ ignoreHTTPSErrors: true,
85
+ });
86
+ try {
87
+ const page = context.pages()[0] || await context.newPage();
88
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
89
+ // If already logged in (browser has valid cookies from previous session), skip waiting
90
+ const currentHost = new URL(page.url()).hostname;
91
+ if (currentHost !== 'operate.intsig.net') {
92
+ console.error('[Auth] Waiting for user to complete login (up to 180s)...');
93
+ await page.waitForURL(url => {
94
+ const u = typeof url === 'string' ? new URL(url) : url;
95
+ return u.hostname === 'operate.intsig.net';
96
+ }, { timeout: 180000 });
97
+ }
98
+ // Ensure the page is fully loaded
99
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { });
100
+ // Extract all cookies
101
+ const cookies = await context.cookies();
102
+ const operateCookies = cookies.filter(c => c.domain.includes('intsig.net') || c.domain.includes('operate'));
103
+ if (operateCookies.length === 0) {
104
+ throw new Error('No cookies captured after login. Please try again.');
105
+ }
106
+ // Navigate to /site/get-config to extract CSRF token
107
+ await page.goto(config.operateBaseUrl + '/site/get-config', { waitUntil: 'domcontentloaded', timeout: 15000 });
108
+ // Re-capture cookies (get-config may refresh JSESSID)
109
+ const allCookies = (await context.cookies()).filter(c => c.domain.includes('intsig.net') || c.domain.includes('operate'));
110
+ const finalCookie = allCookies.map(c => `${c.name}=${c.value}`).join('; ');
111
+ let csrfToken = '';
112
+ try {
113
+ csrfToken = await page.evaluate(`
114
+ (() => {
115
+ const el = document.querySelector('input[name="_csrf"]');
116
+ return el ? el.value : '';
117
+ })()
118
+ `);
119
+ }
120
+ catch { /* ignore */ }
121
+ if (!csrfToken) {
122
+ const html = await page.content();
123
+ const match = html.match(/name="_csrf"\s+value="([^"]+)"/);
124
+ if (match)
125
+ csrfToken = match[1];
126
+ }
127
+ if (!csrfToken) {
128
+ throw new Error('Login succeeded but failed to extract CSRF token. Please try again.');
129
+ }
130
+ console.error('[Auth] Login successful! Cookies and CSRF token captured.');
131
+ return {
132
+ ssoToken: '',
133
+ sessionCookie: finalCookie,
134
+ csrfToken,
135
+ expiresAt: Date.now() + 24 * 60 * 60 * 1000,
136
+ };
137
+ }
138
+ finally {
139
+ await context.close();
140
+ }
141
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
8
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
9
+ const v3_1 = require("zod/v3");
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const operate_client_js_1 = require("./operate-client.js");
13
+ const auth_js_1 = require("./auth.js");
14
+ const utils_js_1 = require("./utils.js");
15
+ // --- Config from env ---
16
+ const OPERATE_BASE_URL = process.env.OPERATE_BASE_URL;
17
+ if (!OPERATE_BASE_URL) {
18
+ console.error('Error: OPERATE_BASE_URL environment variable is required');
19
+ process.exit(1);
20
+ }
21
+ const ssoConfig = {
22
+ operateBaseUrl: OPERATE_BASE_URL,
23
+ };
24
+ const client = new operate_client_js_1.OperateClient(OPERATE_BASE_URL);
25
+ // --- Auth helper ---
26
+ async function requireAuth() {
27
+ if (!client.isAuthenticated()) {
28
+ const savedCreds = await (0, auth_js_1.loadCredentials)();
29
+ if (savedCreds) {
30
+ client.setCredentials(savedCreds);
31
+ console.error('Restored saved credentials (valid until ' + new Date(savedCreds.expiresAt).toLocaleString() + ')');
32
+ }
33
+ }
34
+ if (!client.isAuthenticated()) {
35
+ return "Not authenticated. Please call the 'authenticate' tool first to login via SSO.";
36
+ }
37
+ return null;
38
+ }
39
+ // --- MCP Server ---
40
+ const server = new mcp_js_1.McpServer({
41
+ name: 'language-server',
42
+ version: '1.0.0',
43
+ });
44
+ // Tool: authenticate
45
+ server.tool('authenticate', 'Login to operate platform via SSO QR code scan. Opens browser for authentication.', {}, async () => {
46
+ if (client.isAuthenticated()) {
47
+ return { content: [{ type: 'text', text: "Already authenticated. Use 'logout' tool to re-authenticate." }] };
48
+ }
49
+ try {
50
+ const creds = await (0, auth_js_1.startSsoLogin)(ssoConfig);
51
+ client.setCredentials(creds);
52
+ await (0, auth_js_1.saveCredentials)(creds);
53
+ return { content: [{ type: 'text', text: 'Authentication successful! You can now use all language tools.' }] };
54
+ }
55
+ catch (err) {
56
+ if (err.message?.includes('pre-verification')) {
57
+ return {
58
+ content: [{
59
+ type: 'text',
60
+ text: 'SSO pre-verification completed (no token returned yet). ' +
61
+ 'Please call \'authenticate\' again immediately to complete authentication.',
62
+ }],
63
+ };
64
+ }
65
+ return { content: [{ type: 'text', text: `Authentication failed: ${err.message}` }] };
66
+ }
67
+ });
68
+ // Tool: logout
69
+ server.tool('logout', 'Clear saved credentials and logout.', {}, async () => {
70
+ await (0, auth_js_1.clearCredentials)();
71
+ client.setCredentials(null);
72
+ return { content: [{ type: 'text', text: "Logged out. Call 'authenticate' to login again." }] };
73
+ });
74
+ // Tool: list-products
75
+ server.tool('list-products', '获取多语言平台的所有产品列表。返回产品名称和对应的 product_id。在不确定 product_id 时先调用此工具。', {}, async () => {
76
+ const authErr = await requireAuth();
77
+ if (authErr)
78
+ return { content: [{ type: 'text', text: authErr }] };
79
+ const data = await client.post('/common/product/get-product-list', {});
80
+ if (data.errno !== 0) {
81
+ return { content: [{ type: 'text', text: `错误: ${data.message || JSON.stringify(data)}` }] };
82
+ }
83
+ const products = data.data || {};
84
+ let output = '产品列表:\n\n';
85
+ output += '| product_id | 产品名称 |\n|---|---|\n';
86
+ for (const [name, id] of Object.entries(products)) {
87
+ output += `| ${id} | ${name} |\n`;
88
+ }
89
+ return { content: [{ type: 'text', text: output }] };
90
+ });
91
+ // Tool: list-platforms
92
+ server.tool('list-platforms', '获取多语言平台支持的所有平台列表。返回平台名称和对应的 platform_id。', {}, async () => {
93
+ let output = '平台列表:\n\n';
94
+ output += '| platform_id | 平台名称 |\n|---|---|\n';
95
+ for (const [id, name] of Object.entries(utils_js_1.PLATFORM_MAP)) {
96
+ output += `| ${id} | ${name} |\n`;
97
+ }
98
+ return { content: [{ type: 'text', text: output }] };
99
+ });
100
+ // Tool: get-version-list
101
+ server.tool('get-version-list', '获取指定产品的多语言版本列表。返回每个版本的 version_id、版本号、支持的平台和语言。', {
102
+ product_id: v3_1.z.string().describe('产品ID,先调用 list-products 查询'),
103
+ }, async ({ product_id }) => {
104
+ const authErr = await requireAuth();
105
+ if (authErr)
106
+ return { content: [{ type: 'text', text: authErr }] };
107
+ const data = await client.post('/language/mcp-language/get-version-list', { product_id });
108
+ if (data.errno !== 0) {
109
+ return { content: [{ type: 'text', text: `错误: ${data.message || JSON.stringify(data)}` }] };
110
+ }
111
+ const list = data.data.list || [];
112
+ const summary = list
113
+ .map((v) => {
114
+ const platformNames = (v.platforms || '').split(',').filter(Boolean)
115
+ .map((id) => utils_js_1.PLATFORM_MAP[Number(id)] || `unknown(${id})`).join(', ');
116
+ return `- version_id=${v.version_id}, version=${v.version_number}, platforms=[${platformNames}], languages=${v.supported_languages}`;
117
+ })
118
+ .join('\n');
119
+ return { content: [{ type: 'text', text: `共 ${data.data.total} 个版本:\n${summary}` }] };
120
+ });
121
+ // Tool: search-string
122
+ server.tool('search-string', '按关键词搜索多语言字符串。可指定版本精准搜索,返回 string_id、key、中英文翻译等信息。', {
123
+ product_id: v3_1.z.string().describe('产品ID,先调用 list-products 查询'),
124
+ word: v3_1.z.string().describe('搜索关键词(中文或英文)'),
125
+ version_id: v3_1.z.string().optional().describe('版本ID,不传则搜索所有版本'),
126
+ fuzzy: v3_1.z.string().optional().default('1').describe('1=模糊匹配,0=精确匹配'),
127
+ page: v3_1.z.string().optional().default('1').describe('页码'),
128
+ page_size: v3_1.z.string().optional().default('20').describe('每页条数,最大100'),
129
+ }, async ({ product_id, word, version_id, fuzzy, page, page_size }) => {
130
+ const authErr = await requireAuth();
131
+ if (authErr)
132
+ return { content: [{ type: 'text', text: authErr }] };
133
+ const params = { product_id, word, fuzzy: fuzzy || '1', page: page || '1', page_size: page_size || '20' };
134
+ if (version_id)
135
+ params.version_id = version_id;
136
+ const data = await client.post('/language/mcp-language/get-string-search', params);
137
+ if (data.errno !== 0) {
138
+ return { content: [{ type: 'text', text: `错误: ${data.message || JSON.stringify(data)}` }] };
139
+ }
140
+ const versions = Array.isArray(data.data) ? data.data : (data.data?.list || []);
141
+ if (versions.length === 0) {
142
+ return { content: [{ type: 'text', text: `未找到匹配 "${word}" 的字符串` }] };
143
+ }
144
+ let output = `共 ${versions.length} 个版本有匹配结果:\n\n`;
145
+ for (const version of versions) {
146
+ output += `## 版本 ${version.version_number} (version_id=${version.version_id})\n`;
147
+ const strings = version.ar_string || version.strings || [];
148
+ for (const str of strings) {
149
+ const keys = str.keys
150
+ ? Object.entries(str.keys).map(([p, k]) => `platform_${p}: ${k}`).join(', ')
151
+ : '无';
152
+ const zhCN = str.values?.['1'] || str.values?.['0'] || '';
153
+ const enUS = str.values?.['2'] || str.values?.['0'] || '';
154
+ const zhTW = str.values?.['7'] || '';
155
+ output += `- string_id: ${str.id}\n`;
156
+ output += ` key: ${keys}\n`;
157
+ output += ` 中文: ${zhCN}\n`;
158
+ output += ` 英文: ${enUS}\n`;
159
+ if (zhTW)
160
+ output += ` 繁体: ${zhTW}\n`;
161
+ output += '\n';
162
+ }
163
+ }
164
+ return { content: [{ type: 'text', text: output }] };
165
+ });
166
+ // Tool: export-string
167
+ server.tool('export-string', '搜索多语言字符串并导出为兼容 cs-i18n 的 locale JSON 格式。支持单语言或全部语言导出,自动将 %s 替换为 {0}/{1}/{2}。', {
168
+ product_id: v3_1.z.string().describe('产品ID。常用: 1=CamCard, 2=CamScanner, 44=CS Lite, 47=CS PDF, 53=CS Harmony'),
169
+ word: v3_1.z.string().describe('搜索关键词(中文或英文)'),
170
+ version_id: v3_1.z.string().optional().describe('版本ID,不传则搜索所有版本'),
171
+ platform_id: v3_1.z.string().optional().default('4').describe('平台ID,先调用 list-platforms 查询。默认4=Web'),
172
+ language_id: v3_1.z.string().optional().describe('目标语言ID,不传则导出所有语言。常用: 1=中文, 2=英文, 7=繁体中文'),
173
+ fuzzy: v3_1.z.string().optional().default('0').describe('0=精确匹配(默认,只导出完全一致的字符串), 1=模糊匹配'),
174
+ }, async ({ product_id, word, version_id, platform_id, language_id, fuzzy }) => {
175
+ const authErr = await requireAuth();
176
+ if (authErr)
177
+ return { content: [{ type: 'text', text: authErr }] };
178
+ const params = { product_id, word, fuzzy: fuzzy || '0', page: '1', page_size: '100' };
179
+ if (version_id)
180
+ params.version_id = version_id;
181
+ const data = await client.post('/language/mcp-language/get-string-search', params);
182
+ if (data.errno !== 0) {
183
+ return { content: [{ type: 'text', text: `错误: ${data.message || JSON.stringify(data)}` }] };
184
+ }
185
+ const versions = Array.isArray(data.data) ? data.data : (data.data?.list || []);
186
+ if (versions.length === 0) {
187
+ return { content: [{ type: 'text', text: `未找到匹配 "${word}" 的字符串` }] };
188
+ }
189
+ const allStrings = (0, utils_js_1.extractStrings)(versions, platform_id || '4');
190
+ if (language_id) {
191
+ const localeObj = allStrings[language_id] || {};
192
+ if (Object.keys(localeObj).length === 0) {
193
+ return { content: [{ type: 'text', text: `找到字符串但语言ID=${language_id} 无翻译内容` }] };
194
+ }
195
+ const localeName = utils_js_1.LANGUAGE_LOCALE_MAP[language_id] || `lang_${language_id}`;
196
+ const json = JSON.stringify(localeObj, null, 2);
197
+ return {
198
+ content: [{
199
+ type: 'text',
200
+ text: `导出 ${Object.keys(localeObj).length} 条字符串 → ${localeName}.json:\n\n\`\`\`json\n${json}\n\`\`\``,
201
+ }],
202
+ };
203
+ }
204
+ let output = `共匹配 ${Object.keys(allStrings).length} 种语言:\n\n`;
205
+ for (const [langId, localeObj] of Object.entries(allStrings)) {
206
+ const localeName = utils_js_1.LANGUAGE_LOCALE_MAP[langId] || `lang_${langId}`;
207
+ const json = JSON.stringify(localeObj, null, 2);
208
+ output += `### ${localeName}.json (语言ID=${langId}, ${Object.keys(localeObj).length} 条)\n\`\`\`json\n${json}\n\`\`\`\n\n`;
209
+ }
210
+ return { content: [{ type: 'text', text: output }] };
211
+ });
212
+ // Tool: write-locales
213
+ server.tool('write-locales', '搜索多语言字符串并直接写入项目的 locales 目录,兼容 cs-i18n 工具格式。自动合并到已有的 locale JSON 文件,%s 自动替换为 {0}/{1}/{2}。', {
214
+ product_id: v3_1.z.string().describe('产品ID。常用: 1=CamCard, 2=CamScanner, 44=CS Lite, 47=CS PDF, 53=CS Harmony'),
215
+ word: v3_1.z.string().describe('搜索关键词(中文或英文)'),
216
+ locales_path: v3_1.z.string().describe('locales 目录的绝对路径(需动态检测,不要硬编码)'),
217
+ version_id: v3_1.z.string().optional().describe('版本ID,不传则搜索所有版本'),
218
+ platform_id: v3_1.z.string().optional().default('4').describe('平台ID,先调用 list-platforms 查询。默认4=Web'),
219
+ fuzzy: v3_1.z.string().optional().default('0').describe('0=精确匹配(默认,只写入完全一致的字符串), 1=模糊匹配'),
220
+ }, async ({ product_id, word, locales_path, version_id, platform_id, fuzzy }) => {
221
+ const authErr = await requireAuth();
222
+ if (authErr)
223
+ return { content: [{ type: 'text', text: authErr }] };
224
+ if (!fs_1.default.existsSync(locales_path)) {
225
+ return { content: [{ type: 'text', text: `错误: locales 目录不存在: ${locales_path}` }] };
226
+ }
227
+ const params = { product_id, word, fuzzy: fuzzy || '0', page: '1', page_size: '100' };
228
+ if (version_id)
229
+ params.version_id = version_id;
230
+ const data = await client.post('/language/mcp-language/get-string-search', params);
231
+ if (data.errno !== 0) {
232
+ return { content: [{ type: 'text', text: `错误: ${data.message || JSON.stringify(data)}` }] };
233
+ }
234
+ const versions = Array.isArray(data.data) ? data.data : (data.data?.list || []);
235
+ if (versions.length === 0) {
236
+ return { content: [{ type: 'text', text: `未找到匹配 "${word}" 的字符串` }] };
237
+ }
238
+ const allStrings = (0, utils_js_1.extractStrings)(versions, platform_id || '4');
239
+ const results = [];
240
+ let totalKeys = 0;
241
+ let filesWritten = 0;
242
+ let filesSkipped = 0;
243
+ for (const [langId, newEntries] of Object.entries(allStrings)) {
244
+ const localeName = utils_js_1.LANGUAGE_LOCALE_MAP[langId];
245
+ if (!localeName) {
246
+ filesSkipped++;
247
+ continue;
248
+ }
249
+ const filePath = path_1.default.join(locales_path, `${localeName}.json`);
250
+ if (!fs_1.default.existsSync(filePath)) {
251
+ filesSkipped++;
252
+ continue;
253
+ }
254
+ let existingObj = {};
255
+ try {
256
+ const content = fs_1.default.readFileSync(filePath, 'utf-8');
257
+ existingObj = JSON.parse(content);
258
+ }
259
+ catch {
260
+ // empty or invalid file
261
+ }
262
+ const { merged, keysAdded, keysUpdated } = (0, utils_js_1.mergeLocaleEntries)(existingObj, newEntries);
263
+ fs_1.default.writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n');
264
+ filesWritten++;
265
+ totalKeys += Object.keys(newEntries).length;
266
+ if (keysAdded.length > 0 || keysUpdated.length > 0) {
267
+ results.push(`${localeName}.json: +${keysAdded.length} 新增, ~${keysUpdated.length} 更新`);
268
+ }
269
+ else {
270
+ results.push(`${localeName}.json: 无变化 (${Object.keys(newEntries).length} 条已存在)`);
271
+ }
272
+ }
273
+ // Detect local locale files that got no remote data
274
+ const localLocaleNames = fs_1.default.readdirSync(locales_path)
275
+ .filter(f => f.endsWith('.json'))
276
+ .map(f => f.replace('.json', ''));
277
+ const missingLocales = (0, utils_js_1.findMissingLocales)(localLocaleNames, Object.keys(allStrings));
278
+ let output = `写入完成!\n`;
279
+ output += `- 目录: ${locales_path}\n`;
280
+ output += `- 写入 ${filesWritten} 个文件, 跳过 ${filesSkipped} 个 (项目中不存在)\n`;
281
+ output += `- 共 ${totalKeys} 条字符串\n\n`;
282
+ output += results.map(r => ` ${r}`).join('\n');
283
+ if (missingLocales.length > 0) {
284
+ output += `\n\n⚠️ 以下 ${missingLocales.length} 个本地语言文件未获得远程翻译,未被更新:\n`;
285
+ output += missingLocales.map(name => ` - ${name}.json`).join('\n');
286
+ output += `\n请确认远程平台是否已为这些语言提供翻译。`;
287
+ }
288
+ return { content: [{ type: 'text', text: output }] };
289
+ });
290
+ // --- Start ---
291
+ async function main() {
292
+ const transport = new stdio_js_1.StdioServerTransport();
293
+ await server.connect(transport);
294
+ console.error('Language MCP Server running on stdio');
295
+ }
296
+ main().catch((err) => {
297
+ console.error('Fatal error:', err);
298
+ process.exit(1);
299
+ });
@@ -0,0 +1,15 @@
1
+ export interface OperateCredentials {
2
+ ssoToken: string;
3
+ sessionCookie: string;
4
+ csrfToken: string;
5
+ expiresAt: number;
6
+ }
7
+ export declare class OperateClient {
8
+ private baseUrl;
9
+ private credentials;
10
+ constructor(baseUrl: string);
11
+ setCredentials(creds: OperateCredentials | null): void;
12
+ isAuthenticated(): boolean;
13
+ private postOnce;
14
+ post(path: string, params: Record<string, string>, retries?: number): Promise<any>;
15
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.OperateClient = void 0;
7
+ const http_1 = __importDefault(require("http"));
8
+ const https_1 = __importDefault(require("https"));
9
+ const url_1 = require("url");
10
+ class OperateClient {
11
+ constructor(baseUrl) {
12
+ this.credentials = null;
13
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
14
+ }
15
+ setCredentials(creds) {
16
+ this.credentials = creds;
17
+ }
18
+ isAuthenticated() {
19
+ return !!(this.credentials && Date.now() < this.credentials.expiresAt);
20
+ }
21
+ postOnce(path, params) {
22
+ return new Promise((resolve, reject) => {
23
+ if (!this.credentials) {
24
+ reject(new Error("Not authenticated. Please call the 'authenticate' tool first."));
25
+ return;
26
+ }
27
+ const url = new url_1.URL(this.baseUrl + path);
28
+ const mod = url.protocol === 'https:' ? https_1.default : http_1.default;
29
+ const body = new URLSearchParams(params).toString();
30
+ const options = {
31
+ method: 'POST',
32
+ timeout: 30000,
33
+ headers: {
34
+ 'Cookie': this.credentials.sessionCookie,
35
+ 'Content-Type': 'application/x-www-form-urlencoded',
36
+ 'Content-Length': String(Buffer.byteLength(body)),
37
+ 'x-csrf-token': this.credentials.csrfToken,
38
+ 'x-requested-with': 'XMLHttpRequest',
39
+ 'accept': 'application/json, text/plain, */*',
40
+ 'origin': this.baseUrl,
41
+ 'referer': `${this.baseUrl}/multilanguage/edit-language`,
42
+ },
43
+ };
44
+ const req = mod.request(url.toString(), options, (res) => {
45
+ if (res.statusCode === 302) {
46
+ res.resume();
47
+ reject(new Error("Authentication expired (302 redirect). Please call 'authenticate' to re-login."));
48
+ return;
49
+ }
50
+ let respBody = '';
51
+ res.on('data', (chunk) => (respBody += chunk));
52
+ res.on('end', () => {
53
+ try {
54
+ resolve(JSON.parse(respBody));
55
+ }
56
+ catch {
57
+ reject(new Error(`Invalid JSON response from ${path}: ${respBody.substring(0, 300)}`));
58
+ }
59
+ });
60
+ });
61
+ req.on('error', reject);
62
+ req.on('timeout', () => {
63
+ req.destroy();
64
+ reject(new Error(`Request timeout: ${path}`));
65
+ });
66
+ req.write(body);
67
+ req.end();
68
+ });
69
+ }
70
+ async post(path, params, retries = 2) {
71
+ for (let i = 0; i <= retries; i++) {
72
+ try {
73
+ return await this.postOnce(path, params);
74
+ }
75
+ catch (err) {
76
+ if (i === retries || !err.message?.includes('timeout'))
77
+ throw err;
78
+ console.error(`[Operate] Retry ${i + 1}/${retries} for ${path}: ${err.message}`);
79
+ }
80
+ }
81
+ throw new Error(`Request failed after ${retries} retries: ${path}`);
82
+ }
83
+ }
84
+ exports.OperateClient = OperateClient;
@@ -0,0 +1,17 @@
1
+ export declare const PLATFORM_MAP: Record<number, string>;
2
+ export declare const LANGUAGE_LOCALE_MAP: Record<string, string>;
3
+ export declare function fixPlaceholders(value: string): string;
4
+ export declare function extractStrings(versions: any[], platformId: string): Record<string, Record<string, string>>;
5
+ export interface MergeResult {
6
+ merged: Record<string, string>;
7
+ keysAdded: string[];
8
+ keysUpdated: string[];
9
+ }
10
+ export declare function mergeLocaleEntries(existingObj: Record<string, string>, newEntries: Record<string, string>): MergeResult;
11
+ /**
12
+ * 找出本地存在但远程未返回翻译的 locale 文件名。
13
+ * @param localLocaleNames 本地存在的 locale 名称列表(不含 .json 后缀),如 ['ZhCn', 'EnUs', 'JaJp']
14
+ * @param remoteLanguageIds 远程返回的语言 ID 列表,如 ['1', '2']
15
+ * @returns 本地存在但远程无数据的 locale 名称列表
16
+ */
17
+ export declare function findMissingLocales(localLocaleNames: string[], remoteLanguageIds: string[]): string[];