i18n-mcp-server 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # i18n-MCP-Server
2
+
3
+ 面向前端国际化工作流的 MCP Server,提供以下四类能力:
4
+
5
+ - `extract_replace`:提取硬编码文本并返回源码与语言文件 diff。
6
+ - `doctor_lint`:检查缺失 key、死 key 和硬编码风险点。
7
+ - `refactor_key`:批量重命名 i18n key。
8
+ - `sync_catalog`:预留翻译平台同步适配器接口。
9
+
10
+ ## 设计原则
11
+
12
+ - 默认不静默写入文件,所有修改都先以 unified diff 返回。
13
+ - JS / TS 使用 Babel AST,Vue 使用 SFC 解析器。
14
+ - 保留 JSON 原始缩进风格,减少 Git 噪音。
15
+ - 默认以 `zh-CN.json` 作为基准语言。
16
+
17
+ ## 快速开始
18
+
19
+ ```bash
20
+ npm install
21
+ npm run build
22
+ npm start
23
+ ```
24
+
25
+ ## MCP 工具
26
+
27
+ ### `project_info`
28
+
29
+ 读取项目 `package.json`,自动识别 `vue-i18n`、`react-i18next`、`@angular/localize` 等国际化方案。
30
+
31
+ ### `extract_replace`
32
+
33
+ 输入源码文件与语言目录,扫描静态文本并返回:
34
+
35
+ - 提议生成的 key
36
+ - 源码 diff
37
+ - 语言 JSON diff
38
+
39
+ 默认只生成补丁,不直接写盘。
40
+
41
+ ### `doctor_lint`
42
+
43
+ 以 `zh-CN.json` 为基准:
44
+
45
+ - 对比其他语言缺失 key
46
+ - 扫描代码中未引用的死 key
47
+ - 检测剩余的中日韩硬编码文本
48
+
49
+ ### `refactor_key`
50
+
51
+ 对代码中的 `t('oldKey')` 与多语言 JSON 做联动重命名,返回完整影响文件列表和统一 diff。
52
+
53
+ ### `sync_catalog`
54
+
55
+ MVP 先保留 provider 适配器入口,后续可继续接入 Lokalise / Crowdin / 本地 JSON 导入导出。
56
+
57
+ ## 当前边界
58
+
59
+ 这一版已经具备可编译、可接入的服务骨架,但仍有几项明确的后续增强点:
60
+
61
+ - Vue 模板替换当前是偏保守的 MVP 方案,复杂模板场景还可以继续细化 AST 级改写。
62
+ - `extract_replace` 目前默认依赖调用侧已有 `t()` 上下文,尚未自动补充 import / hook 注入。
63
+ - `sync_catalog` 目前只保留接口和返回结构,尚未接入真实 provider API。
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { detectFramework } from "./utils/framework.js";
7
+ import { runDoctor } from "./tools/doctor.js";
8
+ import { runExtract } from "./tools/extract.js";
9
+ import { runRefactor } from "./tools/refactor.js";
10
+ import { runSync } from "./tools/sync.js";
11
+ const server = new McpServer({
12
+ name: "i18n-mcp-server",
13
+ version: "0.1.0"
14
+ });
15
+ server.tool("project_info", "Inspect the current project and detect which i18n framework is in use.", {
16
+ cwd: z.string().default(process.cwd())
17
+ }, async ({ cwd }) => ({
18
+ content: [
19
+ {
20
+ type: "text",
21
+ text: JSON.stringify({
22
+ cwd,
23
+ framework: await detectFramework(cwd)
24
+ }, null, 2)
25
+ }
26
+ ]
27
+ }));
28
+ server.tool("extract_replace", "Extract hardcoded i18n text from a source file and propose source + locale diffs.", {
29
+ cwd: z.string().default(process.cwd()),
30
+ filePath: z.string(),
31
+ localesDir: z.string(),
32
+ baseLocale: z.string().default("zh-CN"),
33
+ targetLocales: z.array(z.string()).optional(),
34
+ keyStrategy: z.enum(["semantic", "hash"]).default("semantic"),
35
+ namespace: z.string().optional()
36
+ }, async (args) => ({
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: JSON.stringify(await runExtract({
41
+ ...args,
42
+ filePath: path.resolve(args.cwd, args.filePath),
43
+ localesDir: path.resolve(args.cwd, args.localesDir)
44
+ }), null, 2)
45
+ }
46
+ ]
47
+ }));
48
+ server.tool("doctor_lint", "Check locale health, missing translations, dead keys, and remaining hardcoded text.", {
49
+ cwd: z.string().default(process.cwd()),
50
+ srcDir: z.string().default("src"),
51
+ localesDir: z.string(),
52
+ baseLocale: z.string().default("zh-CN"),
53
+ compareLocales: z.array(z.string()).optional()
54
+ }, async (args) => ({
55
+ content: [
56
+ {
57
+ type: "text",
58
+ text: JSON.stringify(await runDoctor({
59
+ ...args,
60
+ localesDir: path.resolve(args.cwd, args.localesDir)
61
+ }), null, 2)
62
+ }
63
+ ]
64
+ }));
65
+ server.tool("refactor_key", "Rename an existing i18n key across source files and locale JSON files, returning a full diff for confirmation.", {
66
+ cwd: z.string().default(process.cwd()),
67
+ srcDir: z.string().default("src"),
68
+ localesDir: z.string(),
69
+ locales: z.array(z.string()),
70
+ oldKey: z.string(),
71
+ newKey: z.string()
72
+ }, async (args) => ({
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: JSON.stringify(await runRefactor({
77
+ ...args,
78
+ localesDir: path.resolve(args.cwd, args.localesDir)
79
+ }), null, 2)
80
+ }
81
+ ]
82
+ }));
83
+ server.tool("sync_catalog", "Sync locale files with an external provider. MVP currently exposes adapter metadata only.", {
84
+ provider: z.enum(["lokalise", "json"]).optional(),
85
+ mode: z.enum(["pull", "push"]).optional(),
86
+ lang: z.string().optional()
87
+ }, async (args) => ({
88
+ content: [
89
+ {
90
+ type: "text",
91
+ text: JSON.stringify(await runSync(args), null, 2)
92
+ }
93
+ ]
94
+ }));
95
+ const transport = new StdioServerTransport();
96
+ await server.connect(transport);
@@ -0,0 +1,2 @@
1
+ import type { File } from "@babel/types";
2
+ export declare function parseCode(source: string, filePath: string): File;
@@ -0,0 +1,19 @@
1
+ import { parse } from "@babel/parser";
2
+ export function parseCode(source, filePath) {
3
+ const isTs = /\.(ts|tsx)$/.test(filePath);
4
+ const isJsx = /\.(jsx|tsx)$/.test(filePath);
5
+ return parse(source, {
6
+ sourceType: "module",
7
+ plugins: [
8
+ "decorators-legacy",
9
+ "importAttributes",
10
+ "classProperties",
11
+ "optionalChaining",
12
+ "nullishCoalescingOperator",
13
+ "topLevelAwait",
14
+ "dynamicImport",
15
+ ...(isTs ? ["typescript"] : []),
16
+ ...(isJsx ? ["jsx"] : [])
17
+ ]
18
+ });
19
+ }
@@ -0,0 +1,5 @@
1
+ export declare function parseVueFile(source: string, filePath: string): {
2
+ descriptor: import("@vue/compiler-sfc").SFCDescriptor;
3
+ templateAst: import("@vue/compiler-core").RootNode | null;
4
+ templateContent: string;
5
+ };
@@ -0,0 +1,12 @@
1
+ import { parse as parseSfc } from "@vue/compiler-sfc";
2
+ import { parse as parseTemplate } from "@vue/compiler-dom";
3
+ export function parseVueFile(source, filePath) {
4
+ const sfc = parseSfc(source, { filename: filePath });
5
+ const templateContent = sfc.descriptor.template?.content ?? "";
6
+ const templateAst = templateContent ? parseTemplate(templateContent, { comments: true }) : null;
7
+ return {
8
+ descriptor: sfc.descriptor,
9
+ templateAst,
10
+ templateContent
11
+ };
12
+ }
@@ -0,0 +1,8 @@
1
+ import type { DoctorReport } from "../types.js";
2
+ export declare function runDoctor(params: {
3
+ cwd: string;
4
+ srcDir?: string;
5
+ localesDir: string;
6
+ baseLocale?: string;
7
+ compareLocales?: string[];
8
+ }): Promise<DoctorReport>;
@@ -0,0 +1,138 @@
1
+ import path from "node:path";
2
+ import traverse from "@babel/traverse";
3
+ import * as t from "@babel/types";
4
+ import { loadLocaleFiles, flattenKeys } from "../utils/json.js";
5
+ import { listCodeFiles, readText } from "../utils/files.js";
6
+ import { parseCode } from "../parsers/babel.js";
7
+ import { parseVueFile } from "../parsers/vue.js";
8
+ const HARD_CODED_TEXT_RE = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/;
9
+ export async function runDoctor(params) {
10
+ const srcDir = params.srcDir ? path.resolve(params.cwd, params.srcDir) : path.join(params.cwd, "src");
11
+ const baseLocale = params.baseLocale ?? "zh-CN";
12
+ const compareLocales = params.compareLocales ?? ["en-US", "ja-JP"];
13
+ const localeFiles = await loadLocaleFiles(params.localesDir, [baseLocale, ...compareLocales]);
14
+ const baseLocaleFile = localeFiles.find((item) => item.locale === baseLocale);
15
+ const baseKeys = flattenKeys(baseLocaleFile.data);
16
+ const missingKeys = compareLocales.flatMap((locale) => {
17
+ const current = localeFiles.find((item) => item.locale === locale);
18
+ if (!current) {
19
+ return [];
20
+ }
21
+ const currentKeys = new Set(flattenKeys(current.data));
22
+ return baseKeys.filter((key) => !currentKeys.has(key)).map((key) => ({ locale, key }));
23
+ });
24
+ const files = await listCodeFiles(srcDir);
25
+ const referencedKeys = new Set();
26
+ const hardcodedTexts = [];
27
+ for (const filePath of files) {
28
+ const source = await readText(filePath);
29
+ if (filePath.endsWith(".vue")) {
30
+ collectVueFindings(filePath, source, referencedKeys, hardcodedTexts);
31
+ }
32
+ else {
33
+ collectJsFindings(filePath, source, referencedKeys, hardcodedTexts);
34
+ }
35
+ }
36
+ const deadKeys = baseKeys.filter((key) => !referencedKeys.has(key));
37
+ return {
38
+ missingKeys,
39
+ deadKeys,
40
+ hardcodedTexts
41
+ };
42
+ }
43
+ function collectJsFindings(filePath, source, referencedKeys, hardcodedTexts) {
44
+ const ast = parseCode(source, filePath);
45
+ traverse(ast, {
46
+ CallExpression(astPath) {
47
+ const callee = astPath.node.callee;
48
+ if (t.isIdentifier(callee, { name: "t" })) {
49
+ const firstArg = astPath.node.arguments[0];
50
+ if (t.isStringLiteral(firstArg)) {
51
+ referencedKeys.add(firstArg.value);
52
+ }
53
+ }
54
+ if (t.isMemberExpression(callee) && t.isIdentifier(callee.property, { name: "log" })) {
55
+ astPath.skip();
56
+ }
57
+ },
58
+ JSXText(astPath) {
59
+ const value = astPath.node.value.trim();
60
+ if (!value || !HARD_CODED_TEXT_RE.test(value)) {
61
+ return;
62
+ }
63
+ const loc = astPath.node.loc?.start;
64
+ if (!loc) {
65
+ return;
66
+ }
67
+ hardcodedTexts.push({
68
+ filePath,
69
+ text: value,
70
+ line: loc.line,
71
+ column: loc.column + 1
72
+ });
73
+ },
74
+ StringLiteral(astPath) {
75
+ const parent = astPath.parent;
76
+ if (t.isJSXAttribute(parent) &&
77
+ t.isJSXIdentifier(parent.name) &&
78
+ !["className", "data-testid"].includes(parent.name.name) &&
79
+ HARD_CODED_TEXT_RE.test(astPath.node.value)) {
80
+ const loc = astPath.node.loc?.start;
81
+ if (!loc) {
82
+ return;
83
+ }
84
+ hardcodedTexts.push({
85
+ filePath,
86
+ text: astPath.node.value,
87
+ line: loc.line,
88
+ column: loc.column + 1
89
+ });
90
+ }
91
+ }
92
+ });
93
+ }
94
+ function collectVueFindings(filePath, source, referencedKeys, hardcodedTexts) {
95
+ const { descriptor, templateAst } = parseVueFile(source, filePath);
96
+ if (descriptor.script?.content) {
97
+ collectJsFindings(filePath, descriptor.script.content, referencedKeys, hardcodedTexts);
98
+ }
99
+ if (descriptor.scriptSetup?.content) {
100
+ collectJsFindings(filePath, descriptor.scriptSetup.content, referencedKeys, hardcodedTexts);
101
+ }
102
+ if (!templateAst) {
103
+ return;
104
+ }
105
+ const walk = (node) => {
106
+ if (node.type === 2) {
107
+ const text = node.content?.trim();
108
+ if (text && HARD_CODED_TEXT_RE.test(text) && node.loc?.start) {
109
+ hardcodedTexts.push({
110
+ filePath,
111
+ text,
112
+ line: node.loc.start.line,
113
+ column: node.loc.start.column
114
+ });
115
+ }
116
+ }
117
+ if (Array.isArray(node.props)) {
118
+ for (const prop of node.props) {
119
+ if (prop.type === 6 &&
120
+ !["class", "data-testid"].includes(prop.name) &&
121
+ prop.value?.content &&
122
+ HARD_CODED_TEXT_RE.test(prop.value.content) &&
123
+ prop.loc?.start) {
124
+ hardcodedTexts.push({
125
+ filePath,
126
+ text: prop.value.content,
127
+ line: prop.loc.start.line,
128
+ column: prop.loc.start.column
129
+ });
130
+ }
131
+ }
132
+ }
133
+ if (Array.isArray(node.children)) {
134
+ node.children.forEach(walk);
135
+ }
136
+ };
137
+ walk(templateAst);
138
+ }
@@ -0,0 +1,14 @@
1
+ import type { ExtractCandidate, KeyStrategy } from "../types.js";
2
+ export declare function runExtract(params: {
3
+ cwd: string;
4
+ filePath: string;
5
+ localesDir: string;
6
+ baseLocale?: string;
7
+ targetLocales?: string[];
8
+ keyStrategy?: KeyStrategy;
9
+ namespace?: string;
10
+ }): Promise<{
11
+ diff: string;
12
+ affectedFiles: string[];
13
+ candidates: ExtractCandidate[];
14
+ }>;
@@ -0,0 +1,144 @@
1
+ import generate from "@babel/generator";
2
+ import traverse from "@babel/traverse";
3
+ import * as t from "@babel/types";
4
+ import MagicString from "magic-string";
5
+ import { parseCode } from "../parsers/babel.js";
6
+ import { parseVueFile } from "../parsers/vue.js";
7
+ import { buildKey } from "../utils/keygen.js";
8
+ import { loadLocaleFiles, flattenKeys, getNestedValue, setNestedValue, stringifyLocale } from "../utils/json.js";
9
+ import { buildUnifiedDiff } from "../utils/diff.js";
10
+ import { readText } from "../utils/files.js";
11
+ const IGNORED_JSX_ATTRS = new Set(["className", "data-testid"]);
12
+ const ASIAN_TEXT_RE = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/;
13
+ export async function runExtract(params) {
14
+ const source = await readText(params.filePath);
15
+ const baseLocale = params.baseLocale ?? "zh-CN";
16
+ const targetLocales = params.targetLocales ?? [baseLocale];
17
+ const keyStrategy = params.keyStrategy ?? "semantic";
18
+ const localeFiles = await loadLocaleFiles(params.localesDir, targetLocales);
19
+ const existingKeys = new Set(localeFiles.flatMap((file) => flattenKeys(file.data)));
20
+ const patches = [];
21
+ const candidates = [];
22
+ const updatedSource = params.filePath.endsWith(".vue")
23
+ ? extractFromVue({
24
+ cwd: params.cwd,
25
+ filePath: params.filePath,
26
+ source,
27
+ namespace: params.namespace,
28
+ keyStrategy,
29
+ existingKeys
30
+ }, candidates)
31
+ : extractFromJsx({
32
+ cwd: params.cwd,
33
+ filePath: params.filePath,
34
+ source,
35
+ namespace: params.namespace,
36
+ keyStrategy,
37
+ existingKeys
38
+ }, candidates);
39
+ if (updatedSource !== source) {
40
+ patches.push({
41
+ path: params.filePath,
42
+ before: source,
43
+ after: updatedSource
44
+ });
45
+ }
46
+ for (const localeFile of localeFiles) {
47
+ let nextData = localeFile.data;
48
+ for (const candidate of candidates) {
49
+ if (getNestedValue(nextData, candidate.key) === undefined) {
50
+ const seedValue = localeFile.locale === baseLocale ? candidate.text : "";
51
+ nextData = setNestedValue(nextData, candidate.key, seedValue);
52
+ }
53
+ }
54
+ const nextRaw = stringifyLocale(nextData, localeFile.indent);
55
+ if (nextRaw !== localeFile.raw) {
56
+ patches.push({
57
+ path: localeFile.path,
58
+ before: localeFile.raw,
59
+ after: nextRaw
60
+ });
61
+ }
62
+ }
63
+ return {
64
+ diff: buildUnifiedDiff(params.cwd, patches),
65
+ affectedFiles: patches.map((patch) => patch.path),
66
+ candidates
67
+ };
68
+ }
69
+ function extractFromJsx(input, candidates) {
70
+ const ast = parseCode(input.source, input.filePath);
71
+ traverse(ast, {
72
+ JSXText(astPath) {
73
+ const text = astPath.node.value.trim();
74
+ if (!text || !ASIAN_TEXT_RE.test(text)) {
75
+ return;
76
+ }
77
+ const key = chooseKey(input, text);
78
+ candidates.push({ text, key, filePath: input.filePath, kind: "jsx-text" });
79
+ astPath.replaceWith(t.jsxExpressionContainer(t.callExpression(t.identifier("t"), [t.stringLiteral(key)])));
80
+ },
81
+ JSXAttribute(astPath) {
82
+ const name = t.isJSXIdentifier(astPath.node.name) ? astPath.node.name.name : "";
83
+ if (IGNORED_JSX_ATTRS.has(name)) {
84
+ return;
85
+ }
86
+ if (t.isStringLiteral(astPath.node.value) && ASIAN_TEXT_RE.test(astPath.node.value.value)) {
87
+ const text = astPath.node.value.value;
88
+ const key = chooseKey(input, text);
89
+ candidates.push({ text, key, filePath: input.filePath, kind: "jsx-attr" });
90
+ astPath.node.value = t.jsxExpressionContainer(t.callExpression(t.identifier("t"), [t.stringLiteral(key)]));
91
+ }
92
+ }
93
+ });
94
+ return generate(ast, { retainLines: true }, input.source).code;
95
+ }
96
+ function extractFromVue(input, candidates) {
97
+ const { descriptor } = parseVueFile(input.source, input.filePath);
98
+ const templateBlock = descriptor.template;
99
+ if (!templateBlock || !templateBlock.content) {
100
+ return input.source;
101
+ }
102
+ const templateSource = templateBlock.content;
103
+ const magic = new MagicString(input.source);
104
+ let attrPattern = /\b(placeholder|title|alt|label)="([^"]*[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af][^"]*)"/g;
105
+ let attrMatch;
106
+ while ((attrMatch = attrPattern.exec(templateSource)) !== null) {
107
+ const attrName = attrMatch[1];
108
+ const text = attrMatch[2].trim();
109
+ if (!text) {
110
+ continue;
111
+ }
112
+ const key = chooseKey(input, text);
113
+ candidates.push({ text, key, filePath: input.filePath, kind: "vue-attr" });
114
+ const start = templateBlock.loc.start.offset + attrMatch.index;
115
+ const end = start + attrMatch[0].length;
116
+ magic.overwrite(start, end, `:${attrName}="t('${key}')"`);
117
+ }
118
+ let textPattern = />([^<>\n]*[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af][^<>]*)</g;
119
+ let textMatch;
120
+ while ((textMatch = textPattern.exec(templateSource)) !== null) {
121
+ const text = textMatch[1].trim();
122
+ if (!text) {
123
+ continue;
124
+ }
125
+ const key = chooseKey(input, text);
126
+ candidates.push({ text, key, filePath: input.filePath, kind: "vue-text" });
127
+ const innerIndex = textMatch[0].indexOf(textMatch[1]);
128
+ const start = templateBlock.loc.start.offset + textMatch.index + innerIndex;
129
+ const end = start + textMatch[1].length;
130
+ magic.overwrite(start, end, `{{ t('${key}') }}`);
131
+ }
132
+ return magic.toString();
133
+ }
134
+ function chooseKey(input, text) {
135
+ const proposed = buildKey({
136
+ cwd: input.cwd,
137
+ filePath: input.filePath,
138
+ text,
139
+ namespace: input.namespace,
140
+ strategy: input.keyStrategy
141
+ });
142
+ input.existingKeys.add(proposed);
143
+ return proposed;
144
+ }
@@ -0,0 +1,11 @@
1
+ export declare function runRefactor(params: {
2
+ cwd: string;
3
+ srcDir?: string;
4
+ localesDir: string;
5
+ locales: string[];
6
+ oldKey: string;
7
+ newKey: string;
8
+ }): Promise<{
9
+ affectedFiles: string[];
10
+ diff: string;
11
+ }>;
@@ -0,0 +1,91 @@
1
+ import path from "node:path";
2
+ import generate from "@babel/generator";
3
+ import traverse from "@babel/traverse";
4
+ import * as t from "@babel/types";
5
+ import MagicString from "magic-string";
6
+ import { parseCode } from "../parsers/babel.js";
7
+ import { parseVueFile } from "../parsers/vue.js";
8
+ import { listCodeFiles, readText } from "../utils/files.js";
9
+ import { buildUnifiedDiff } from "../utils/diff.js";
10
+ import { deleteNestedValue, getNestedValue, loadLocaleFiles, setNestedValue, stringifyLocale } from "../utils/json.js";
11
+ export async function runRefactor(params) {
12
+ const srcDir = params.srcDir ? path.resolve(params.cwd, params.srcDir) : path.join(params.cwd, "src");
13
+ const patches = [];
14
+ const codeFiles = await listCodeFiles(srcDir);
15
+ for (const filePath of codeFiles) {
16
+ const source = await readText(filePath);
17
+ const next = filePath.endsWith(".vue")
18
+ ? renameInVueFile(source, filePath, params.oldKey, params.newKey)
19
+ : renameInJsFile(source, filePath, params.oldKey, params.newKey);
20
+ if (next !== source) {
21
+ patches.push({
22
+ path: filePath,
23
+ before: source,
24
+ after: next
25
+ });
26
+ }
27
+ }
28
+ const localeFiles = await loadLocaleFiles(params.localesDir, params.locales);
29
+ for (const localeFile of localeFiles) {
30
+ const oldValue = getNestedValue(localeFile.data, params.oldKey);
31
+ if (oldValue === undefined) {
32
+ continue;
33
+ }
34
+ const nextData = setNestedValue(deleteNestedValue(localeFile.data, params.oldKey), params.newKey, oldValue);
35
+ const nextRaw = stringifyLocale(nextData, localeFile.indent);
36
+ if (nextRaw !== localeFile.raw) {
37
+ patches.push({
38
+ path: localeFile.path,
39
+ before: localeFile.raw,
40
+ after: nextRaw
41
+ });
42
+ }
43
+ }
44
+ return {
45
+ affectedFiles: patches.map((patch) => patch.path),
46
+ diff: buildUnifiedDiff(params.cwd, patches)
47
+ };
48
+ }
49
+ function renameInJsFile(source, filePath, oldKey, newKey) {
50
+ const ast = parseCode(source, filePath);
51
+ let touched = false;
52
+ traverse(ast, {
53
+ CallExpression(astPath) {
54
+ const callee = astPath.node.callee;
55
+ if (!t.isIdentifier(callee, { name: "t" })) {
56
+ return;
57
+ }
58
+ const firstArg = astPath.node.arguments[0];
59
+ if (t.isStringLiteral(firstArg) && firstArg.value === oldKey) {
60
+ firstArg.value = newKey;
61
+ touched = true;
62
+ }
63
+ }
64
+ });
65
+ return touched ? generate(ast, { retainLines: true }, source).code : source;
66
+ }
67
+ function renameInVueFile(source, filePath, oldKey, newKey) {
68
+ const { descriptor } = parseVueFile(source, filePath);
69
+ const magic = new MagicString(source);
70
+ if (descriptor.script) {
71
+ const next = renameInJsFile(descriptor.script.content, filePath, oldKey, newKey);
72
+ if (next !== descriptor.script.content) {
73
+ magic.overwrite(descriptor.script.loc.start.offset, descriptor.script.loc.end.offset, next);
74
+ }
75
+ }
76
+ if (descriptor.scriptSetup) {
77
+ const next = renameInJsFile(descriptor.scriptSetup.content, filePath, oldKey, newKey);
78
+ if (next !== descriptor.scriptSetup.content) {
79
+ magic.overwrite(descriptor.scriptSetup.loc.start.offset, descriptor.scriptSetup.loc.end.offset, next);
80
+ }
81
+ }
82
+ if (descriptor.template?.content) {
83
+ const replaced = descriptor.template.content
84
+ .replaceAll(`t('${oldKey}')`, `t('${newKey}')`)
85
+ .replaceAll(`t("${oldKey}")`, `t("${newKey}")`);
86
+ if (replaced !== descriptor.template.content) {
87
+ magic.overwrite(descriptor.template.loc.start.offset, descriptor.template.loc.end.offset, replaced);
88
+ }
89
+ }
90
+ return magic.toString();
91
+ }
@@ -0,0 +1,11 @@
1
+ export declare function runSync(params: {
2
+ provider?: "lokalise" | "json";
3
+ mode?: "pull" | "push";
4
+ lang?: string;
5
+ }): Promise<{
6
+ status: string;
7
+ provider: "lokalise" | "json";
8
+ mode: "push" | "pull";
9
+ lang: string;
10
+ message: string;
11
+ }>;
@@ -0,0 +1,9 @@
1
+ export async function runSync(params) {
2
+ return {
3
+ status: "planned",
4
+ provider: params.provider ?? "json",
5
+ mode: params.mode ?? "pull",
6
+ lang: params.lang ?? "all",
7
+ message: "MVP 当前保留 provider 适配层,建议下一步接入 Lokalise API 或本地 JSON 导入导出流水线。"
8
+ };
9
+ }
@@ -0,0 +1,38 @@
1
+ export type KeyStrategy = "semantic" | "hash";
2
+ export interface LocaleFile {
3
+ locale: string;
4
+ path: string;
5
+ indent: string;
6
+ data: Record<string, unknown>;
7
+ raw: string;
8
+ }
9
+ export interface FilePatch {
10
+ path: string;
11
+ before: string;
12
+ after: string;
13
+ }
14
+ export interface ExtractCandidate {
15
+ text: string;
16
+ key: string;
17
+ filePath: string;
18
+ kind: "jsx-text" | "jsx-attr" | "vue-text" | "vue-attr";
19
+ }
20
+ export interface HardcodedTextFinding {
21
+ filePath: string;
22
+ text: string;
23
+ line: number;
24
+ column: number;
25
+ }
26
+ export interface MissingKeyFinding {
27
+ locale: string;
28
+ key: string;
29
+ }
30
+ export interface DoctorReport {
31
+ missingKeys: MissingKeyFinding[];
32
+ deadKeys: string[];
33
+ hardcodedTexts: HardcodedTextFinding[];
34
+ }
35
+ export interface FrameworkInfo {
36
+ framework: "vue-i18n" | "react-i18next" | "@angular/localize" | "unknown";
37
+ packageManagerHint: string[];
38
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { FilePatch } from "../types.js";
2
+ export declare function buildUnifiedDiff(cwd: string, patches: FilePatch[]): string;
@@ -0,0 +1,10 @@
1
+ import path from "node:path";
2
+ import { createTwoFilesPatch } from "diff";
3
+ export function buildUnifiedDiff(cwd, patches) {
4
+ return patches
5
+ .map((patch) => {
6
+ const relativePath = path.relative(cwd, patch.path).split(path.sep).join("/");
7
+ return createTwoFilesPatch(relativePath, relativePath, patch.before, patch.after, "before", "after");
8
+ })
9
+ .join("\n");
10
+ }
@@ -0,0 +1,3 @@
1
+ export declare function readText(filePath: string): Promise<string>;
2
+ export declare function listCodeFiles(rootDir: string): Promise<string[]>;
3
+ export declare function toPosixRelative(rootDir: string, targetPath: string): string;
@@ -0,0 +1,18 @@
1
+ import fg from "fast-glob";
2
+ import path from "node:path";
3
+ import { promises as fs } from "node:fs";
4
+ const CODE_PATTERNS = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.vue"];
5
+ export async function readText(filePath) {
6
+ return fs.readFile(filePath, "utf8");
7
+ }
8
+ export async function listCodeFiles(rootDir) {
9
+ return fg(CODE_PATTERNS, {
10
+ cwd: rootDir,
11
+ absolute: true,
12
+ onlyFiles: true,
13
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"]
14
+ });
15
+ }
16
+ export function toPosixRelative(rootDir, targetPath) {
17
+ return path.relative(rootDir, targetPath).split(path.sep).join("/");
18
+ }
@@ -0,0 +1,2 @@
1
+ import type { FrameworkInfo } from "../types.js";
2
+ export declare function detectFramework(cwd: string): Promise<FrameworkInfo>;
@@ -0,0 +1,23 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ export async function detectFramework(cwd) {
4
+ const packageJsonPath = path.join(cwd, "package.json");
5
+ try {
6
+ const raw = await fs.readFile(packageJsonPath, "utf8");
7
+ const pkg = JSON.parse(raw);
8
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
9
+ if ("vue-i18n" in deps) {
10
+ return { framework: "vue-i18n", packageManagerHint: ["vue-i18n"] };
11
+ }
12
+ if ("react-i18next" in deps || "i18next" in deps) {
13
+ return { framework: "react-i18next", packageManagerHint: ["react-i18next", "i18next"] };
14
+ }
15
+ if ("@angular/localize" in deps) {
16
+ return { framework: "@angular/localize", packageManagerHint: ["@angular/localize"] };
17
+ }
18
+ }
19
+ catch {
20
+ return { framework: "unknown", packageManagerHint: [] };
21
+ }
22
+ return { framework: "unknown", packageManagerHint: [] };
23
+ }
@@ -0,0 +1,7 @@
1
+ import type { LocaleFile } from "../types.js";
2
+ export declare function loadLocaleFiles(localesDir: string, locales: string[]): Promise<LocaleFile[]>;
3
+ export declare function flattenKeys(input: Record<string, unknown>, prefix?: string): string[];
4
+ export declare function getNestedValue(input: Record<string, unknown>, dottedKey: string): unknown;
5
+ export declare function setNestedValue(input: Record<string, unknown>, dottedKey: string, value: unknown): Record<string, unknown>;
6
+ export declare function deleteNestedValue(input: Record<string, unknown>, dottedKey: string): Record<string, unknown>;
7
+ export declare function stringifyLocale(data: Record<string, unknown>, indent: string): string;
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import detectIndent from "detect-indent";
4
+ export async function loadLocaleFiles(localesDir, locales) {
5
+ return Promise.all(locales.map(async (locale) => {
6
+ const localePath = path.join(localesDir, `${locale}.json`);
7
+ const raw = await fs.readFile(localePath, "utf8");
8
+ const indent = detectIndent(raw).indent || " ";
9
+ return {
10
+ locale,
11
+ path: localePath,
12
+ indent,
13
+ data: JSON.parse(raw),
14
+ raw
15
+ };
16
+ }));
17
+ }
18
+ export function flattenKeys(input, prefix = "") {
19
+ return Object.entries(input).flatMap(([key, value]) => {
20
+ const nextKey = prefix ? `${prefix}.${key}` : key;
21
+ if (value && typeof value === "object" && !Array.isArray(value)) {
22
+ return flattenKeys(value, nextKey);
23
+ }
24
+ return [nextKey];
25
+ });
26
+ }
27
+ export function getNestedValue(input, dottedKey) {
28
+ return dottedKey.split(".").reduce((current, segment) => {
29
+ if (!current || typeof current !== "object") {
30
+ return undefined;
31
+ }
32
+ return current[segment];
33
+ }, input);
34
+ }
35
+ export function setNestedValue(input, dottedKey, value) {
36
+ const clone = structuredClone(input);
37
+ const segments = dottedKey.split(".");
38
+ let cursor = clone;
39
+ for (const segment of segments.slice(0, -1)) {
40
+ if (!cursor[segment] || typeof cursor[segment] !== "object" || Array.isArray(cursor[segment])) {
41
+ cursor[segment] = {};
42
+ }
43
+ cursor = cursor[segment];
44
+ }
45
+ cursor[segments.at(-1)] = value;
46
+ return clone;
47
+ }
48
+ export function deleteNestedValue(input, dottedKey) {
49
+ const clone = structuredClone(input);
50
+ const segments = dottedKey.split(".");
51
+ const trail = [];
52
+ let cursor = clone;
53
+ for (const segment of segments.slice(0, -1)) {
54
+ const next = cursor[segment];
55
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
56
+ return clone;
57
+ }
58
+ trail.push({ parent: cursor, key: segment });
59
+ cursor = next;
60
+ }
61
+ delete cursor[segments.at(-1)];
62
+ for (let index = trail.length - 1; index >= 0; index -= 1) {
63
+ const { parent, key } = trail[index];
64
+ const child = parent[key];
65
+ if (child && typeof child === "object" && !Array.isArray(child) && Object.keys(child).length === 0) {
66
+ delete parent[key];
67
+ }
68
+ }
69
+ return clone;
70
+ }
71
+ export function stringifyLocale(data, indent) {
72
+ return `${JSON.stringify(data, null, indent)}\n`;
73
+ }
@@ -0,0 +1,9 @@
1
+ import type { KeyStrategy } from "../types.js";
2
+ export declare function buildKey(input: {
3
+ cwd: string;
4
+ filePath: string;
5
+ text: string;
6
+ componentName?: string;
7
+ namespace?: string;
8
+ strategy: KeyStrategy;
9
+ }): string;
@@ -0,0 +1,29 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ export function buildKey(input) {
4
+ if (input.strategy === "hash") {
5
+ const hash = crypto.createHash("sha1").update(input.text).digest("hex").slice(0, 8);
6
+ return input.namespace ? `${input.namespace}.${hash}` : hash;
7
+ }
8
+ const relative = path.relative(input.cwd, input.filePath).replace(/\.[^.]+$/, "");
9
+ const normalizedPath = relative
10
+ .split(path.sep)
11
+ .join(".")
12
+ .replace(/[^a-zA-Z0-9.]+/g, ".")
13
+ .replace(/\.+/g, ".")
14
+ .replace(/^\.|\.$/g, "")
15
+ .toLowerCase();
16
+ const semanticTail = input.text
17
+ .trim()
18
+ .toLowerCase()
19
+ .replace(/[\s/]+/g, ".")
20
+ .replace(/[^a-z0-9.\u4e00-\u9fa5_-]+/g, "")
21
+ .replace(/\.+/g, ".")
22
+ .replace(/^\.|\.$/g, "")
23
+ .slice(0, 48) || "text";
24
+ return [input.namespace, normalizedPath, input.componentName, semanticTail]
25
+ .filter(Boolean)
26
+ .join(".")
27
+ .replace(/\.+/g, ".")
28
+ .replace(/^\.|\.$/g, "");
29
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "i18n-mcp-server",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for frontend i18n extraction, linting, refactoring, and sync workflows.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": "./dist/index.js"
10
+ },
11
+ "bin": {
12
+ "i18n-mcp-server": "dist/index.js"
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "check": "tsc -p tsconfig.json --noEmit",
17
+ "start": "node dist/index.js",
18
+ "prepare": "npm run build",
19
+ "prepublishOnly": "npm run check && npm run build"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "mcp",
28
+ "i18n",
29
+ "typescript",
30
+ "vue",
31
+ "react"
32
+ ],
33
+ "homepage": "https://github.com/tota92/i18n-mcp-server#readme",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/tota92/i18n-mcp-server.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/tota92/i18n-mcp-server/issues"
40
+ },
41
+ "license": "MIT",
42
+ "dependencies": {
43
+ "@babel/generator": "^7.28.0",
44
+ "@babel/parser": "^7.28.0",
45
+ "@babel/traverse": "^7.28.0",
46
+ "@babel/types": "^7.28.0",
47
+ "@modelcontextprotocol/sdk": "^1.12.0",
48
+ "@vue/compiler-dom": "^3.5.13",
49
+ "@vue/compiler-sfc": "^3.5.13",
50
+ "detect-indent": "^7.0.1",
51
+ "diff": "^7.0.0",
52
+ "fast-glob": "^3.3.3",
53
+ "magic-string": "^0.30.17",
54
+ "zod": "^3.24.4"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^22.15.3",
58
+ "typescript": "^5.8.3"
59
+ }
60
+ }