@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.
- package/.claude-plugin/marketplace.json +20 -0
- package/README.md +48 -0
- package/dist/auth.d.ts +12 -0
- package/dist/auth.js +141 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +299 -0
- package/dist/operate-client.d.ts +15 -0
- package/dist/operate-client.js +84 -0
- package/dist/utils.d.ts +17 -0
- package/dist/utils.js +112 -0
- package/index.js +672 -0
- package/package.json +28 -0
- package/plugins/i18n/.claude-plugin/plugin.json +8 -0
- package/plugins/i18n/.mcp.json +13 -0
- package/plugins/i18n/skills/i18n/SKILL.md +112 -0
- package/plugins/yapi/.claude-plugin/plugin.json +8 -0
- package/plugins/yapi/.mcp.json +13 -0
- package/plugins/yapi/skills/yapi/SKILL.md +76 -0
- package/setup.sh +96 -0
- package/src/auth.ts +158 -0
- package/src/index.ts +376 -0
- package/src/operate-client.ts +94 -0
- package/src/utils.test.ts +337 -0
- package/src/utils.ts +127 -0
- package/test-auth-e2e.js +278 -0
- package/tsconfig.json +17 -0
- package/update-cookie.sh +75 -0
package/index.js
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import https from "node:https";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import nodePath from "node:path";
|
|
11
|
+
import { execSync, exec } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { URL } from "node:url";
|
|
14
|
+
import { startSsoLogin, createCredentialsManager } from "mcp-sso-auth";
|
|
15
|
+
|
|
16
|
+
// 脚本所在目录
|
|
17
|
+
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
// ==================== SSO 认证模块(使用 mcp-sso-auth) ====================
|
|
20
|
+
|
|
21
|
+
const SSO_LOGIN_URL = process.env.SSO_LOGIN_URL || "https://web-sso.intsig.net/login";
|
|
22
|
+
const SSO_PLATFORM_ID = process.env.SSO_PLATFORM_ID || "OdliDeAnVtlUA5cGwwxZPHUyXtqPCcNw";
|
|
23
|
+
const SSO_CALLBACK_DOMAIN = process.env.SSO_CALLBACK_DOMAIN || "https://www-sandbox.camscanner.com/activity/mcp-auth-callback";
|
|
24
|
+
const SSO_CALLBACK_PORT = parseInt(process.env.SSO_CALLBACK_PORT || "9877", 10);
|
|
25
|
+
|
|
26
|
+
const credentialsManager = createCredentialsManager("language-mcp");
|
|
27
|
+
const { load: loadCredentials, save: saveCredentials, clear: clearCredentials } = credentialsManager;
|
|
28
|
+
|
|
29
|
+
/** Fetch session cookies and CSRF token from operate platform using sso_token */
|
|
30
|
+
function fetchSessionInfo(baseUrl, ssoToken) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const url = new URL(baseUrl + "/site/get-config");
|
|
33
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
34
|
+
const options = {
|
|
35
|
+
timeout: 10000,
|
|
36
|
+
headers: {
|
|
37
|
+
Cookie: `sso_token=${ssoToken}`,
|
|
38
|
+
"x-requested-with": "XMLHttpRequest",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const req = mod.get(url.toString(), options, (res) => {
|
|
42
|
+
// Capture all cookies from set-cookie headers
|
|
43
|
+
const rawCookies = res.headers["set-cookie"] || [];
|
|
44
|
+
const cookiePairs = [];
|
|
45
|
+
let csrfCookie = "";
|
|
46
|
+
for (const c of rawCookies) {
|
|
47
|
+
const m = c.match(/^([^=]+)=([^;]*)/);
|
|
48
|
+
if (m) {
|
|
49
|
+
cookiePairs.push(`${m[1]}=${m[2]}`);
|
|
50
|
+
if (m[1] === "_csrf") csrfCookie = m[2];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Also include sso_token in the cookie string
|
|
54
|
+
cookiePairs.push(`sso_token=${ssoToken}`);
|
|
55
|
+
const sessionCookie = cookiePairs.join("; ");
|
|
56
|
+
|
|
57
|
+
let body = "";
|
|
58
|
+
res.on("data", (chunk) => (body += chunk));
|
|
59
|
+
res.on("end", () => {
|
|
60
|
+
// Extract client-side CSRF token from hidden form field
|
|
61
|
+
let csrfToken = "";
|
|
62
|
+
const fieldMatch = body.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
63
|
+
if (fieldMatch) {
|
|
64
|
+
csrfToken = fieldMatch[1];
|
|
65
|
+
} else {
|
|
66
|
+
const altMatch = body.match(/value="([^"]+)"\s*>/);
|
|
67
|
+
if (altMatch && csrfCookie) csrfToken = altMatch[1];
|
|
68
|
+
}
|
|
69
|
+
resolve({ sessionCookie, csrfToken });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
req.on("error", reject);
|
|
73
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Session fetch timeout")); });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// In-memory credentials
|
|
78
|
+
let currentCredentials = loadCredentials();
|
|
79
|
+
if (currentCredentials) {
|
|
80
|
+
console.error("Restored saved credentials (valid until " + new Date(currentCredentials.expiresAt).toLocaleString() + ")");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isAuthenticated() {
|
|
84
|
+
return !!(currentCredentials && Date.now() < currentCredentials.expiresAt);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requireAuth() {
|
|
88
|
+
if (!isAuthenticated()) {
|
|
89
|
+
return "Not authenticated. Please call the 'authenticate' tool first to login via SSO.";
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ==================== 原有配置 ====================
|
|
95
|
+
|
|
96
|
+
// 从文件读取(保留作为 fallback)
|
|
97
|
+
function loadFile(filename) {
|
|
98
|
+
try {
|
|
99
|
+
return fs.readFileSync(nodePath.join(__dirname, filename), "utf-8").trim();
|
|
100
|
+
} catch {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 基础配置
|
|
106
|
+
const BASE_URL = process.env.OPERATE_BASE_URL || "https://operate.intsig.net";
|
|
107
|
+
const PORT = parseInt(process.env.PORT || "3100", 10);
|
|
108
|
+
const MODE = process.argv.includes("--http") ? "http" : "stdio";
|
|
109
|
+
|
|
110
|
+
// 产品ID映射
|
|
111
|
+
const PRODUCT_MAP = {
|
|
112
|
+
1: "CamCard",
|
|
113
|
+
2: "CamScanner",
|
|
114
|
+
44: "CamScanner Lite",
|
|
115
|
+
47: "CS PDF",
|
|
116
|
+
53: "CS Harmony",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// 语言ID → locale 文件名映射(兼容 cs-i18n 工具)
|
|
120
|
+
const LANGUAGE_LOCALE_MAP = {
|
|
121
|
+
"1": "ZhCn", // 简体中文
|
|
122
|
+
"2": "EnUs", // 英语
|
|
123
|
+
"3": "JaJp", // 日语
|
|
124
|
+
"4": "KoKr", // 韩语
|
|
125
|
+
"5": "FrFr", // 法语
|
|
126
|
+
"6": "DeDe", // 德语
|
|
127
|
+
"7": "ZhTw", // 繁体中文
|
|
128
|
+
"8": "PtBr", // 巴西葡萄牙语
|
|
129
|
+
"9": "EsEs", // 西班牙语
|
|
130
|
+
"10": "ItIt", // 意大利语
|
|
131
|
+
"11": "RuRu", // 俄语
|
|
132
|
+
"12": "TrTr", // 土耳其语
|
|
133
|
+
"13": "ArSa", // 阿拉伯语
|
|
134
|
+
"14": "ThTh", // 泰语(Th)
|
|
135
|
+
"15": "PlPl", // 波兰语
|
|
136
|
+
"16": "ViVn", // 越南语
|
|
137
|
+
"17": "InId", // 印度尼西亚语
|
|
138
|
+
"19": "MsMy", // 马来语
|
|
139
|
+
"20": "NlNl", // 荷兰语
|
|
140
|
+
"22": "HiDi", // 印地语
|
|
141
|
+
"23": "BnBd", // 孟加拉语
|
|
142
|
+
"24": "CsCs", // 捷克语
|
|
143
|
+
"25": "SkSk", // 斯洛伐克语
|
|
144
|
+
"26": "FilPh", // 菲律宾语
|
|
145
|
+
"27": "ElEl", // 希腊语
|
|
146
|
+
"28": "PtPt", // 葡萄牙语
|
|
147
|
+
"29": "RoRo", // 罗马尼亚语
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// %s → {0}, {1}, {2}... 替换
|
|
151
|
+
function fixPlaceholders(value) {
|
|
152
|
+
let cnt = 0;
|
|
153
|
+
return value
|
|
154
|
+
.replace(/%s/g, () => `{${cnt++}}`)
|
|
155
|
+
.replace(/\\"/g, '"')
|
|
156
|
+
.replace(/\\n/g, '\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 从 API 返回的版本数据中提取 key-value pairs
|
|
160
|
+
function extractStrings(versions, platformId) {
|
|
161
|
+
// 收集所有字符串,按 language_id 分组:{ langId: { key: value } }
|
|
162
|
+
const result = {};
|
|
163
|
+
for (const version of versions) {
|
|
164
|
+
const strings = version.ar_string || version.strings || [];
|
|
165
|
+
// 获取该版本支持的语言列表
|
|
166
|
+
const languages = version.ar_language || [];
|
|
167
|
+
for (const str of strings) {
|
|
168
|
+
const key = str.keys?.[platformId] || str.keys?.["0"] || Object.values(str.keys || {})[0];
|
|
169
|
+
if (!key) continue;
|
|
170
|
+
for (const langId of languages) {
|
|
171
|
+
const value = str.values?.[langId];
|
|
172
|
+
if (!value) continue;
|
|
173
|
+
if (!result[langId]) result[langId] = {};
|
|
174
|
+
result[langId][key] = fixPlaceholders(value);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 通用请求方法 — 优先使用 SSO 凭据,fallback 到文件/环境变量
|
|
182
|
+
async function operatePost(urlPath, params) {
|
|
183
|
+
let cookie, csrfToken;
|
|
184
|
+
|
|
185
|
+
// Extra cookies from file/env (e.g. nginx st cookie)
|
|
186
|
+
const extraCookie = loadFile(".cookie") || process.env.OPERATE_COOKIE || "";
|
|
187
|
+
|
|
188
|
+
if (isAuthenticated()) {
|
|
189
|
+
const ssoCookie = currentCredentials.sessionCookie || `sso_token=${currentCredentials.ssoToken}`;
|
|
190
|
+
// SSO cookie 优先,extraCookie 作为补充放在后面
|
|
191
|
+
cookie = extraCookie ? `${ssoCookie}; ${extraCookie}` : ssoCookie;
|
|
192
|
+
csrfToken = currentCredentials.csrfToken || loadFile(".csrf-token") || process.env.OPERATE_CSRF_TOKEN || "";
|
|
193
|
+
} else {
|
|
194
|
+
cookie = extraCookie;
|
|
195
|
+
csrfToken = loadFile(".csrf-token") || process.env.OPERATE_CSRF_TOKEN || "";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const body = new URLSearchParams(params).toString();
|
|
199
|
+
const url = `${BASE_URL}${urlPath}`;
|
|
200
|
+
|
|
201
|
+
console.error(`[DEBUG] curl ${url}, auth: ${isAuthenticated() ? "SSO" : "file/env"}`);
|
|
202
|
+
|
|
203
|
+
const curlScript = `#!/bin/bash
|
|
204
|
+
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy NO_PROXY
|
|
205
|
+
curl -s '${url}' \\
|
|
206
|
+
-H 'accept: application/json, text/plain, */*' \\
|
|
207
|
+
-H 'content-type: application/x-www-form-urlencoded' \\
|
|
208
|
+
-H 'x-requested-with: XMLHttpRequest' \\
|
|
209
|
+
-H 'x-csrf-token: ${csrfToken}' \\
|
|
210
|
+
-H 'origin: ${BASE_URL}' \\
|
|
211
|
+
-H 'referer: ${BASE_URL}/multilanguage/edit-language' \\
|
|
212
|
+
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36' \\
|
|
213
|
+
-b '${cookie.replace(/'/g, "\\'")}' \\
|
|
214
|
+
--data-raw '${body.replace(/'/g, "\\'")}'
|
|
215
|
+
`;
|
|
216
|
+
const scriptFile = nodePath.join(__dirname, ".curl-request.sh");
|
|
217
|
+
fs.writeFileSync(scriptFile, curlScript, { mode: 0o755 });
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const text = execSync(`bash '${scriptFile}'`, {
|
|
221
|
+
encoding: "utf-8",
|
|
222
|
+
timeout: 30000,
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(text);
|
|
226
|
+
} catch {
|
|
227
|
+
throw new Error(`响应非 JSON: ${text.substring(0, 300)}`);
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (err.message?.startsWith("响应非 JSON")) throw err;
|
|
231
|
+
throw new Error(`curl 请求失败: ${err.stderr?.substring(0, 500) || err.message?.substring(0, 300)}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 注册所有 tools
|
|
236
|
+
function registerTools(server) {
|
|
237
|
+
// Tool 0: SSO 认证
|
|
238
|
+
server.tool(
|
|
239
|
+
"authenticate",
|
|
240
|
+
"Login to operate platform via SSO QR code scan. Opens browser for authentication.",
|
|
241
|
+
{},
|
|
242
|
+
async () => {
|
|
243
|
+
if (isAuthenticated()) {
|
|
244
|
+
return { content: [{ type: "text", text: "Already authenticated. Use 'logout' tool to re-authenticate." }] };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const creds = await startSsoLogin({
|
|
248
|
+
ssoLoginUrl: SSO_LOGIN_URL,
|
|
249
|
+
platformId: SSO_PLATFORM_ID,
|
|
250
|
+
callbackDomain: SSO_CALLBACK_DOMAIN,
|
|
251
|
+
callbackPort: SSO_CALLBACK_PORT,
|
|
252
|
+
serverName: "多语言 MCP Server",
|
|
253
|
+
async exchangeToken(ssoToken) {
|
|
254
|
+
const { sessionCookie, csrfToken } = await fetchSessionInfo(BASE_URL, ssoToken);
|
|
255
|
+
const result = { ssoToken, sessionCookie, csrfToken, expiresAt: Date.now() + 24 * 60 * 60 * 1000 };
|
|
256
|
+
saveCredentials(result);
|
|
257
|
+
return result;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
currentCredentials = creds;
|
|
261
|
+
return { content: [{ type: "text", text: "Authentication successful! You can now use all language tools." }] };
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return { content: [{ type: "text", text: `Authentication failed: ${err.message}` }] };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Tool: logout
|
|
269
|
+
server.tool(
|
|
270
|
+
"logout",
|
|
271
|
+
"Clear saved credentials and logout.",
|
|
272
|
+
{},
|
|
273
|
+
async () => {
|
|
274
|
+
clearCredentials();
|
|
275
|
+
currentCredentials = null;
|
|
276
|
+
return { content: [{ type: "text", text: "Logged out. Call 'authenticate' to login again." }] };
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Tool 1: 获取版本列表
|
|
281
|
+
server.tool(
|
|
282
|
+
"get-version-list",
|
|
283
|
+
"获取指定产品的多语言版本列表。返回每个版本的 version_id、版本号、支持的平台和语言。",
|
|
284
|
+
{
|
|
285
|
+
product_id: z
|
|
286
|
+
.string()
|
|
287
|
+
.describe(
|
|
288
|
+
`产品ID。常用值: ${Object.entries(PRODUCT_MAP)
|
|
289
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
290
|
+
.join(", ")}`
|
|
291
|
+
),
|
|
292
|
+
},
|
|
293
|
+
async ({ product_id }) => {
|
|
294
|
+
const authError = requireAuth();
|
|
295
|
+
if (authError) return { content: [{ type: "text", text: authError }] };
|
|
296
|
+
const data = await operatePost(
|
|
297
|
+
"/language/language/get-version-list",
|
|
298
|
+
{ product_id }
|
|
299
|
+
);
|
|
300
|
+
if (data.errno !== 0) {
|
|
301
|
+
return {
|
|
302
|
+
content: [
|
|
303
|
+
{
|
|
304
|
+
type: "text",
|
|
305
|
+
text: `错误: ${data.message || JSON.stringify(data)}`,
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const list = data.data.list || [];
|
|
311
|
+
const summary = list
|
|
312
|
+
.map(
|
|
313
|
+
(v) =>
|
|
314
|
+
`- version_id=${v.version_id}, version=${v.version_number}, platforms=${v.platforms}, languages=${v.supported_languages}`
|
|
315
|
+
)
|
|
316
|
+
.join("\n");
|
|
317
|
+
return {
|
|
318
|
+
content: [
|
|
319
|
+
{
|
|
320
|
+
type: "text",
|
|
321
|
+
text: `共 ${data.data.total} 个版本:\n${summary}`,
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Tool 2: 搜索多语言字符串
|
|
329
|
+
server.tool(
|
|
330
|
+
"search-string",
|
|
331
|
+
"按关键词搜索多语言字符串。可指定版本精准搜索,返回 string_id、key、中英文翻译等信息。",
|
|
332
|
+
{
|
|
333
|
+
product_id: z.string().describe("产品ID,如 2 表示 CamScanner"),
|
|
334
|
+
word: z.string().describe("搜索关键词(中文或英文)"),
|
|
335
|
+
version_id: z
|
|
336
|
+
.string()
|
|
337
|
+
.optional()
|
|
338
|
+
.describe("版本ID,不传则搜索所有版本"),
|
|
339
|
+
fuzzy: z
|
|
340
|
+
.string()
|
|
341
|
+
.optional()
|
|
342
|
+
.default("1")
|
|
343
|
+
.describe("1=模糊匹配,0=精确匹配"),
|
|
344
|
+
page: z.string().optional().default("1").describe("页码"),
|
|
345
|
+
page_size: z
|
|
346
|
+
.string()
|
|
347
|
+
.optional()
|
|
348
|
+
.default("20")
|
|
349
|
+
.describe("每页条数,最大100"),
|
|
350
|
+
},
|
|
351
|
+
async ({ product_id, word, version_id, fuzzy, page, page_size }) => {
|
|
352
|
+
const authError = requireAuth();
|
|
353
|
+
if (authError) return { content: [{ type: "text", text: authError }] };
|
|
354
|
+
const params = { product_id, word, fuzzy, page, page_size };
|
|
355
|
+
if (version_id) params.version_id = version_id;
|
|
356
|
+
|
|
357
|
+
const data = await operatePost(
|
|
358
|
+
"/language/language/get-string-search",
|
|
359
|
+
params
|
|
360
|
+
);
|
|
361
|
+
if (data.errno !== 0) {
|
|
362
|
+
return {
|
|
363
|
+
content: [
|
|
364
|
+
{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: `错误: ${data.message || JSON.stringify(data)}`,
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// data.data 可能是数组(直接版本列表)或对象(含 list 字段)
|
|
373
|
+
const versions = Array.isArray(data.data) ? data.data : (data.data?.list || []);
|
|
374
|
+
if (versions.length === 0) {
|
|
375
|
+
return {
|
|
376
|
+
content: [
|
|
377
|
+
{ type: "text", text: `未找到匹配 "${word}" 的字符串` },
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let output = `共 ${versions.length} 个版本有匹配结果:\n\n`;
|
|
383
|
+
for (const version of versions) {
|
|
384
|
+
output += `## 版本 ${version.version_number} (version_id=${version.version_id})\n`;
|
|
385
|
+
const strings = version.ar_string || version.strings || [];
|
|
386
|
+
for (const str of strings) {
|
|
387
|
+
const keys = str.keys
|
|
388
|
+
? Object.entries(str.keys)
|
|
389
|
+
.map(([p, k]) => `platform_${p}: ${k}`)
|
|
390
|
+
.join(", ")
|
|
391
|
+
: "无";
|
|
392
|
+
const zhCN = str.values?.["1"] || str.values?.["0"] || "";
|
|
393
|
+
const enUS = str.values?.["2"] || str.values?.["0"] || "";
|
|
394
|
+
const zhTW = str.values?.["7"] || "";
|
|
395
|
+
output += `- string_id: ${str.id}\n`;
|
|
396
|
+
output += ` key: ${keys}\n`;
|
|
397
|
+
output += ` 中文: ${zhCN}\n`;
|
|
398
|
+
output += ` 英文: ${enUS}\n`;
|
|
399
|
+
if (zhTW) output += ` 繁体: ${zhTW}\n`;
|
|
400
|
+
output += "\n";
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { content: [{ type: "text", text: output }] };
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Tool 3: 导出字符串为 locale JSON 格式(兼容 cs-i18n)
|
|
408
|
+
server.tool(
|
|
409
|
+
"export-string",
|
|
410
|
+
'搜索多语言字符串并导出为兼容 cs-i18n 的 locale JSON 格式。支持单语言或全部语言导出,自动将 %s 替换为 {0}/{1}/{2}。',
|
|
411
|
+
{
|
|
412
|
+
product_id: z.string().describe("产品ID,如 2 表示 CamScanner"),
|
|
413
|
+
word: z.string().describe("搜索关键词(中文或英文)"),
|
|
414
|
+
version_id: z
|
|
415
|
+
.string()
|
|
416
|
+
.optional()
|
|
417
|
+
.describe("版本ID,不传则搜索所有版本"),
|
|
418
|
+
platform_id: z
|
|
419
|
+
.string()
|
|
420
|
+
.optional()
|
|
421
|
+
.default("4")
|
|
422
|
+
.describe("平台ID,用于选择 key: 1=Android, 3=iOS, 4=Web"),
|
|
423
|
+
language_id: z
|
|
424
|
+
.string()
|
|
425
|
+
.optional()
|
|
426
|
+
.describe(
|
|
427
|
+
"目标语言ID,不传则导出所有语言。常用: 1=中文, 2=英文, 7=繁体中文"
|
|
428
|
+
),
|
|
429
|
+
},
|
|
430
|
+
async ({ product_id, word, version_id, platform_id, language_id }) => {
|
|
431
|
+
const authError = requireAuth();
|
|
432
|
+
if (authError) return { content: [{ type: "text", text: authError }] };
|
|
433
|
+
const params = { product_id, word, fuzzy: "1", page: "1", page_size: "100" };
|
|
434
|
+
if (version_id) params.version_id = version_id;
|
|
435
|
+
|
|
436
|
+
const data = await operatePost(
|
|
437
|
+
"/language/language/get-string-search",
|
|
438
|
+
params
|
|
439
|
+
);
|
|
440
|
+
if (data.errno !== 0) {
|
|
441
|
+
return {
|
|
442
|
+
content: [{ type: "text", text: `错误: ${data.message || JSON.stringify(data)}` }],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const versions = Array.isArray(data.data) ? data.data : (data.data?.list || []);
|
|
447
|
+
if (versions.length === 0) {
|
|
448
|
+
return {
|
|
449
|
+
content: [{ type: "text", text: `未找到匹配 "${word}" 的字符串` }],
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const allStrings = extractStrings(versions, platform_id);
|
|
454
|
+
|
|
455
|
+
if (language_id) {
|
|
456
|
+
// 单语言导出
|
|
457
|
+
const localeObj = allStrings[language_id] || {};
|
|
458
|
+
if (Object.keys(localeObj).length === 0) {
|
|
459
|
+
return {
|
|
460
|
+
content: [{ type: "text", text: `找到字符串但语言ID=${language_id} 无翻译内容` }],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const localeName = LANGUAGE_LOCALE_MAP[language_id] || `lang_${language_id}`;
|
|
464
|
+
const json = JSON.stringify(localeObj, null, 2);
|
|
465
|
+
return {
|
|
466
|
+
content: [{
|
|
467
|
+
type: "text",
|
|
468
|
+
text: `导出 ${Object.keys(localeObj).length} 条字符串 → ${localeName}.json:\n\n\`\`\`json\n${json}\n\`\`\``,
|
|
469
|
+
}],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// 全部语言导出
|
|
474
|
+
let output = `共匹配 ${Object.keys(allStrings).length} 种语言:\n\n`;
|
|
475
|
+
for (const [langId, localeObj] of Object.entries(allStrings)) {
|
|
476
|
+
const localeName = LANGUAGE_LOCALE_MAP[langId] || `lang_${langId}`;
|
|
477
|
+
const json = JSON.stringify(localeObj, null, 2);
|
|
478
|
+
output += `### ${localeName}.json (语言ID=${langId}, ${Object.keys(localeObj).length} 条)\n\`\`\`json\n${json}\n\`\`\`\n\n`;
|
|
479
|
+
}
|
|
480
|
+
return { content: [{ type: "text", text: output }] };
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// Tool 4: 写入 locales 目录(兼容 cs-i18n 项目结构)
|
|
485
|
+
server.tool(
|
|
486
|
+
"write-locales",
|
|
487
|
+
'搜索多语言字符串并直接写入项目的 locales 目录,兼容 cs-i18n 工具格式。自动合并到已有的 locale JSON 文件,%s 自动替换为 {0}/{1}/{2}。',
|
|
488
|
+
{
|
|
489
|
+
product_id: z.string().describe("产品ID,如 2 表示 CamScanner"),
|
|
490
|
+
word: z.string().describe("搜索关键词(中文或英文)"),
|
|
491
|
+
locales_path: z
|
|
492
|
+
.string()
|
|
493
|
+
.describe("locales 目录的绝对路径,如 /Users/xxx/project/src/locales"),
|
|
494
|
+
version_id: z
|
|
495
|
+
.string()
|
|
496
|
+
.optional()
|
|
497
|
+
.describe("版本ID,不传则搜索所有版本"),
|
|
498
|
+
platform_id: z
|
|
499
|
+
.string()
|
|
500
|
+
.optional()
|
|
501
|
+
.default("4")
|
|
502
|
+
.describe("平台ID,用于选择 key: 1=Android, 3=iOS, 4=Web"),
|
|
503
|
+
},
|
|
504
|
+
async ({ product_id, word, locales_path, version_id, platform_id }) => {
|
|
505
|
+
const authError = requireAuth();
|
|
506
|
+
if (authError) return { content: [{ type: "text", text: authError }] };
|
|
507
|
+
// 验证 locales 目录存在
|
|
508
|
+
if (!fs.existsSync(locales_path)) {
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: "text", text: `错误: locales 目录不存在: ${locales_path}` }],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const params = { product_id, word, fuzzy: "1", page: "1", page_size: "100" };
|
|
515
|
+
if (version_id) params.version_id = version_id;
|
|
516
|
+
|
|
517
|
+
const data = await operatePost(
|
|
518
|
+
"/language/language/get-string-search",
|
|
519
|
+
params
|
|
520
|
+
);
|
|
521
|
+
if (data.errno !== 0) {
|
|
522
|
+
return {
|
|
523
|
+
content: [{ type: "text", text: `错误: ${data.message || JSON.stringify(data)}` }],
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const versions = Array.isArray(data.data) ? data.data : (data.data?.list || []);
|
|
528
|
+
if (versions.length === 0) {
|
|
529
|
+
return {
|
|
530
|
+
content: [{ type: "text", text: `未找到匹配 "${word}" 的字符串` }],
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const allStrings = extractStrings(versions, platform_id);
|
|
535
|
+
|
|
536
|
+
// 备份并写入每个语言文件
|
|
537
|
+
const results = [];
|
|
538
|
+
let totalKeys = 0;
|
|
539
|
+
let filesWritten = 0;
|
|
540
|
+
let filesSkipped = 0;
|
|
541
|
+
|
|
542
|
+
for (const [langId, newEntries] of Object.entries(allStrings)) {
|
|
543
|
+
const localeName = LANGUAGE_LOCALE_MAP[langId];
|
|
544
|
+
if (!localeName) {
|
|
545
|
+
filesSkipped++;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const filePath = nodePath.join(locales_path, `${localeName}.json`);
|
|
550
|
+
if (!fs.existsSync(filePath)) {
|
|
551
|
+
filesSkipped++;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 读取已有文件
|
|
556
|
+
let existingObj = {};
|
|
557
|
+
try {
|
|
558
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
559
|
+
existingObj = JSON.parse(content);
|
|
560
|
+
} catch {
|
|
561
|
+
// 文件为空或格式错误,使用空对象
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// 修复已有值中的 %s
|
|
565
|
+
for (const key of Object.keys(existingObj)) {
|
|
566
|
+
if (typeof existingObj[key] === "string" && existingObj[key].includes("%s")) {
|
|
567
|
+
existingObj[key] = fixPlaceholders(existingObj[key]);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 保留 insert_before_this_line 的位置约定
|
|
572
|
+
const insertMarker = existingObj["insert_before_this_line"];
|
|
573
|
+
delete existingObj["insert_before_this_line"];
|
|
574
|
+
|
|
575
|
+
// 合并新字符串(覆盖已有)
|
|
576
|
+
const keysAdded = [];
|
|
577
|
+
const keysUpdated = [];
|
|
578
|
+
for (const [key, value] of Object.entries(newEntries)) {
|
|
579
|
+
if (existingObj[key] === undefined) {
|
|
580
|
+
keysAdded.push(key);
|
|
581
|
+
} else if (existingObj[key] !== value) {
|
|
582
|
+
keysUpdated.push(key);
|
|
583
|
+
}
|
|
584
|
+
existingObj[key] = value;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 恢复 insert_before_this_line
|
|
588
|
+
if (insertMarker) {
|
|
589
|
+
existingObj["insert_before_this_line"] = insertMarker;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// 写入文件
|
|
593
|
+
fs.writeFileSync(filePath, JSON.stringify(existingObj, null, 2) + "\n");
|
|
594
|
+
filesWritten++;
|
|
595
|
+
totalKeys += Object.keys(newEntries).length;
|
|
596
|
+
if (keysAdded.length > 0 || keysUpdated.length > 0) {
|
|
597
|
+
results.push(`${localeName}.json: +${keysAdded.length} 新增, ~${keysUpdated.length} 更新`);
|
|
598
|
+
} else {
|
|
599
|
+
results.push(`${localeName}.json: 无变化 (${Object.keys(newEntries).length} 条已存在)`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let output = `写入完成!\n`;
|
|
604
|
+
output += `- 目录: ${locales_path}\n`;
|
|
605
|
+
output += `- 写入 ${filesWritten} 个文件, 跳过 ${filesSkipped} 个 (项目中不存在)\n`;
|
|
606
|
+
output += `- 共 ${totalKeys} 条字符串\n\n`;
|
|
607
|
+
output += results.map(r => ` ${r}`).join("\n");
|
|
608
|
+
|
|
609
|
+
return { content: [{ type: "text", text: output }] };
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// HTTP 模式启动
|
|
615
|
+
async function startHttp() {
|
|
616
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
617
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
618
|
+
|
|
619
|
+
// 健康检查
|
|
620
|
+
if (url.pathname === "/health") {
|
|
621
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
622
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// MCP endpoint
|
|
627
|
+
if (url.pathname === "/mcp") {
|
|
628
|
+
const server = new McpServer({
|
|
629
|
+
name: "language-server",
|
|
630
|
+
version: "1.0.0",
|
|
631
|
+
});
|
|
632
|
+
registerTools(server);
|
|
633
|
+
|
|
634
|
+
const transport = new StreamableHTTPServerTransport({
|
|
635
|
+
sessionIdGenerator: undefined,
|
|
636
|
+
});
|
|
637
|
+
res.on("close", () => {
|
|
638
|
+
transport.close();
|
|
639
|
+
server.close();
|
|
640
|
+
});
|
|
641
|
+
await server.connect(transport);
|
|
642
|
+
await transport.handleRequest(req, res);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
res.writeHead(404);
|
|
647
|
+
res.end("Not Found");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
httpServer.listen(PORT, "0.0.0.0", () => {
|
|
651
|
+
console.log(`Language MCP Server (HTTP) running at http://0.0.0.0:${PORT}/mcp`);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Stdio 模式启动
|
|
656
|
+
async function startStdio() {
|
|
657
|
+
const server = new McpServer({
|
|
658
|
+
name: "language-server",
|
|
659
|
+
version: "1.0.0",
|
|
660
|
+
});
|
|
661
|
+
registerTools(server);
|
|
662
|
+
const transport = new StdioServerTransport();
|
|
663
|
+
await server.connect(transport);
|
|
664
|
+
console.error("Language MCP Server (stdio) running");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 入口
|
|
668
|
+
const main = MODE === "http" ? startHttp : startStdio;
|
|
669
|
+
main().catch((err) => {
|
|
670
|
+
console.error("Server error:", err);
|
|
671
|
+
process.exit(1);
|
|
672
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@camscanner/mcp-language-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP Server for multi-language string management",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-language-server": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "ISC",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"playwright-core": "^1.59.1",
|
|
21
|
+
"zod": "^4.3.6"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20.11.0",
|
|
25
|
+
"typescript": "^5.3.0",
|
|
26
|
+
"vitest": "^4.1.4"
|
|
27
|
+
}
|
|
28
|
+
}
|