harmonyos-best-practices-mcp 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # HarmonyOS 最佳实践 MCP 服务器
2
2
 
3
- 把鸿蒙应用开发最佳实践资料(452 篇文档 + 186 个示例仓库)通过 MCP 暴露为检索工具,供 Claude Code / Cursor / Cline 等 MCP 客户端在开发时动态调用。
3
+ 把鸿蒙应用开发最佳实践资料通过 MCP 暴露为检索工具,供 Claude Code / Cursor / Cline 等 MCP 客户端在开发时动态调用。
4
4
 
5
5
  **文档(9MB)随包发布,装包即用、零配置。** 代码(8GB)不随包,按需配置本地路径(见下)。
6
6
 
@@ -10,7 +10,7 @@
10
10
  |------|------|
11
11
  | `search_best_practices({query, limit?})` | 全文检索文档(中文友好),返回相关度排序的文档列表(含主题、是否有代码、代码仓库名) |
12
12
  | `get_doc({name})` | 读取指定文档(docId)的完整 Markdown 正文 |
13
- | `get_code_example({docName})` | 返回文档关联的参考代码:本地仓库绝对路径、远程 URL、入口 `.ets/.ts` 文件 |
13
+ | `get_code_example({docName})` | 返回文档关联的参考代码:本地仓库绝对路径、远程 URL、README 简介、入口 `.ets/.ts` 文件(带用途注释) |
14
14
  | `list_by_topic({topic?})` | 按大类浏览(稳定性/性能/媒体/功耗/一多…);省略参数返回所有大类及文档数 |
15
15
 
16
16
  ## 安装(最终用户)
@@ -18,10 +18,10 @@
18
18
  无需 clone 本仓库。直接用 npx 或全局安装:
19
19
 
20
20
  ```bash
21
- # 方式一:一次性运行(推荐)
22
- npx harmonyos-best-practices-mcp
21
+ # 方式一:一次性运行(推荐,每次自动拉最新版)
22
+ npx -y harmonyos-best-practices-mcp
23
23
 
24
- # 方式二:全局安装
24
+ # 方式二:全局安装(需手动更新)
25
25
  npm install -g harmonyos-best-practices-mcp
26
26
  ```
27
27
 
@@ -90,6 +90,33 @@ Claude Code 配置加 env:
90
90
  | `BP_INDEX` | 包内 `data/index.md` | 索引文件(一般无需改) |
91
91
  | `BP_CODE_DIR` | 空 | 本地代码根目录;为空时 `get_code_example` 只给 URL |
92
92
 
93
+ ## 更新
94
+
95
+ 文档和服务器会持续更新(版本号见 `package.json`)。
96
+
97
+ **更新服务器**:
98
+
99
+ ```bash
100
+ # npx -y 方式:无需手动操作,每次启动自动拉最新版
101
+ # 全局安装方式:手动更新
102
+ npm update -g harmonyos-best-practices-mcp
103
+ # 或锁定最新版
104
+ npm install -g harmonyos-best-practices-mcp@latest
105
+ ```
106
+
107
+ 更新后**重启 AI 客户端**(Claude Code / Cursor / Cline 等),让新进程加载新版 MCP。
108
+
109
+ **更新文档**:文档随包内置(`data/docs/`),更新 npm 包即同步更新 452 篇文档,无需单独操作。
110
+
111
+ **更新代码包**(仅当启用了本地代码读取):GitHub Release 有新版 `harmonyos-best-practices-code.tar.gz` 时,重新下载解压覆盖 `BP_CODE_DIR` 指向的目录即可。
112
+
113
+ **查看版本**:
114
+ ```bash
115
+ npm view harmonyos-best-practices-mcp version # 最新发布版
116
+ npm ls -g harmonyos-best-practices-mcp # 本地已装版本
117
+ ```
118
+ 或看客户端 MCP 面板里服务器的 `version` 字段。
119
+
93
120
  ## 开发与发布(维护者)
94
121
 
95
122
  ```bash
package/dist/data.js CHANGED
@@ -67,6 +67,8 @@ function parseIndex(indexFile) {
67
67
  subtitle: "",
68
68
  codeRefs: [],
69
69
  hasCode: false,
70
+ readmeDigest: "",
71
+ headings: "",
70
72
  };
71
73
  continue;
72
74
  }
@@ -134,6 +136,8 @@ function parseCrawlLog(docsDir, docs) {
134
136
  subtitle,
135
137
  codeRefs: [],
136
138
  hasCode: false,
139
+ readmeDigest: "",
140
+ headings: "",
137
141
  });
138
142
  }
139
143
  }
@@ -158,9 +162,141 @@ export function getStore() {
158
162
  arr.push(meta.docId);
159
163
  topics.set(meta.topic, arr);
160
164
  }
165
+ // Preload a short README digest for each cloned repo, used for search scoring.
166
+ // Only read when codeDir is configured; otherwise leave empty (search still works on doc text).
167
+ if (codeDir) {
168
+ for (const meta of docs.values()) {
169
+ if (!meta.hasCode)
170
+ continue;
171
+ const digests = [];
172
+ for (const ref of meta.codeRefs) {
173
+ if (ref.status !== "cloned" || !ref.localPath)
174
+ continue;
175
+ const repoAbs = resolveCodePath(codeDir, ref.localPath);
176
+ const intro = readReadmeIntro(repoAbs);
177
+ if (intro)
178
+ digests.push(intro);
179
+ }
180
+ meta.readmeDigest = digests.join(" \n ");
181
+ }
182
+ }
183
+ // Preload all markdown headings for each doc — rich section-level signal for search.
184
+ for (const meta of docs.values()) {
185
+ meta.headings = extractHeadings(docsDir, meta.docId);
186
+ }
161
187
  _store = { docs, topics, docsDir, codeDir };
162
188
  return _store;
163
189
  }
190
+ /** Extract all markdown heading texts from a doc, joined by space. Light & full-text. */
191
+ export function extractHeadings(docsDir, docId) {
192
+ const file = path.join(docsDir, `${docId}.md`);
193
+ let text;
194
+ try {
195
+ text = fs.readFileSync(file, "utf8");
196
+ }
197
+ catch {
198
+ return "";
199
+ }
200
+ const heads = [];
201
+ for (const line of text.split(/\r?\n/)) {
202
+ const m = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
203
+ if (m) {
204
+ // strip inline markdown noise (**bold**, `code`, links)
205
+ const clean = m[1]
206
+ .replace(/\*\*/g, "")
207
+ .replace(/`([^`]+)`/g, "$1")
208
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
209
+ .trim();
210
+ if (clean)
211
+ heads.push(clean);
212
+ }
213
+ }
214
+ return heads.join(" ");
215
+ }
216
+ /** Read the intro section (project overview) of a repo's README.md, ~300 chars. */
217
+ export function readReadmeIntro(repoAbs) {
218
+ const file = path.join(repoAbs, "README.md");
219
+ let text;
220
+ try {
221
+ text = fs.readFileSync(file, "utf8");
222
+ }
223
+ catch {
224
+ return "";
225
+ }
226
+ // Try to isolate the "项目简介/Overview/简介" section: from that heading up to the
227
+ // next section (效果预览/使用说明/How to Use/Effect/工程目录...).
228
+ const startMatch = text.match(/(?:^|\n)#+\s*(项目简介|简介|Overview|Project Overview|介绍)\s*\n/i);
229
+ let body;
230
+ if (startMatch) {
231
+ const after = text.slice((startMatch.index ?? 0) + startMatch[0].length);
232
+ const endMatch = after.match(/\n#+\s*(效果预览|使用说明|工程目录|Project Directory|How to Use|Effect|具体实现|相关概念|目录结构)/i);
233
+ body = endMatch ? after.slice(0, endMatch.index) : after;
234
+ }
235
+ else {
236
+ // No intro heading: take everything before the first ## section after the title.
237
+ const lines = text.split(/\r?\n/);
238
+ body = lines.slice(1, 40).join("\n");
239
+ }
240
+ const cleaned = body
241
+ .replace(/```[\s\S]*?```/g, " ")
242
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
243
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
244
+ .replace(/^#+\s*/gm, "")
245
+ .replace(/[*_`>]/g, "")
246
+ .replace(/\s+/g, " ")
247
+ .trim();
248
+ return cleaned.slice(0, 300);
249
+ }
250
+ /** Read a repo's README.md full text (for get_code_example). Null if missing. */
251
+ export function readRepoReadme(repoAbs) {
252
+ const file = path.join(repoAbs, "README.md");
253
+ if (!fs.existsSync(file))
254
+ return null;
255
+ try {
256
+ return fs.readFileSync(file, "utf8");
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ }
262
+ /**
263
+ * Parse the "工程目录 / Project Directory" tree from a README, returning a map of
264
+ * basename -> purpose comment (e.g. "VideoList.ets" -> "视频列表组件").
265
+ * Returns empty map if no tree found.
266
+ */
267
+ export function parseReadmeTree(readme) {
268
+ const out = new Map();
269
+ if (!readme)
270
+ return out;
271
+ // Find the directory-tree code block: usually after "工程目录"/"Project Directory"/"目录结构".
272
+ const marker = readme.match(/(工程目录|Project Directory|目录结构|目录说明)[^\n]*\n/i);
273
+ let start = marker ? marker.index + marker[0].length : -1;
274
+ let block = "";
275
+ if (start >= 0) {
276
+ // Take the fenced ``` ... ``` block that follows, or indented tree lines.
277
+ const after = readme.slice(start);
278
+ const fence = after.match(/```[\s\S]*?```/);
279
+ if (fence) {
280
+ block = fence[0].replace(/```/g, "");
281
+ }
282
+ else {
283
+ // fallback: lines that look like tree (contain ├── or │ or //)
284
+ const lines = after.split(/\r?\n/).filter((l) => /^[│├└\s]*[─-]/.test(l) || l.includes("//"));
285
+ block = lines.join("\n");
286
+ }
287
+ }
288
+ if (!block)
289
+ return out;
290
+ // Each tree line: optional tree chars, a path/file, optional "// purpose"
291
+ for (const line of block.split(/\r?\n/)) {
292
+ const m = line.match(/([A-Za-z0-9_\-./]+\.(?:ets|ts))\s*\/\/\s*(.+?)\s*$/);
293
+ if (m) {
294
+ const base = m[1].split("/").pop().trim();
295
+ out.set(base, m[2].trim());
296
+ }
297
+ }
298
+ return out;
299
+ }
164
300
  /** Read a doc's full markdown body. Returns null if missing. */
165
301
  export function readDoc(docsDir, docId) {
166
302
  const file = path.join(docsDir, `${docId}.md`);
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { z } from "zod";
7
- import { getStore, readDoc, resolveCodePath } from "./data.js";
7
+ import { getStore, readDoc, resolveCodePath, readRepoReadme, readReadmeIntro, parseReadmeTree, } from "./data.js";
8
8
  import { search } from "./search.js";
9
9
  const store = getStore();
10
10
  const server = new McpServer({
@@ -79,11 +79,20 @@ function formatCodeRef(ref) {
79
79
  const exists = fs.existsSync(abs);
80
80
  lines.push(` 本地: ${abs}${exists ? "" : " ⚠️ 路径在磁盘上不存在(检查 BP_CODE_DIR 或自行克隆该仓库)"}`);
81
81
  if (exists) {
82
+ // README 简介 + 工程目录树(若有),帮助快速理解仓库用途与文件分工.
83
+ const intro = readReadmeIntro(abs);
84
+ if (intro)
85
+ lines.push(` 简介: ${intro}`);
86
+ const readme = readRepoReadme(abs);
87
+ const tree = readme ? parseReadmeTree(readme) : new Map();
82
88
  const entries = listEntryFiles(abs);
83
89
  if (entries.length) {
84
90
  lines.push(` 入口文件:`);
85
- for (const e of entries)
86
- lines.push(` - ${e}`);
91
+ for (const e of entries) {
92
+ const base = path.basename(e);
93
+ const purpose = tree.get(base);
94
+ lines.push(purpose ? ` - ${e} // ${purpose}` : ` - ${e}`);
95
+ }
87
96
  }
88
97
  }
89
98
  return lines.join("\n");
@@ -91,50 +100,102 @@ function formatCodeRef(ref) {
91
100
  function statusText(s) {
92
101
  return s === "cloned" ? "✅ 已克隆" : s === "skipped" ? "⏭️ 核心仓跳过" : "❌ 缺失";
93
102
  }
94
- /** Find up to 8 entry source files (.ets/.ts) in a repo, preferring pages/. */
103
+ /**
104
+ * Find up to 8 entry source files (.ets/.ts) in a repo.
105
+ * Strategy: try a list of well-known source dirs (pages > view/components > ets),
106
+ * each scanned recursively; if none yield files, walk the whole repo (skipping
107
+ * noise dirs) so unusual module layouts (library/, features/, products/) still work.
108
+ */
95
109
  function listEntryFiles(repoAbs) {
110
+ const LIMIT = 8;
111
+ // Ordered: prefer pages/, then view/components, then the ets root of each module.
112
+ // Covers entry/, library/, features/<x>/, products/<phone>/ style layouts.
96
113
  const candidates = [
97
114
  "entry/src/main/ets/pages",
115
+ "entry/src/main/ets/view",
116
+ "entry/src/main/ets/views",
117
+ "entry/src/main/ets/components",
98
118
  "entry/src/main/ets",
119
+ "library/src/main/ets/pages",
120
+ "library/src/main/ets",
99
121
  ];
100
122
  for (const c of candidates) {
101
- const dir = path.join(repoAbs, c.replace(/\//g, path.sep));
102
- if (fs.existsSync(dir)) {
103
- const files = listDirFiles(dir, [".ets", ".ts"], 8);
123
+ const dir = path.join(repoAbs, c);
124
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
125
+ const files = listDirFiles(dir, [".ets", ".ts"], LIMIT);
104
126
  if (files.length)
105
127
  return files;
106
128
  }
107
129
  }
108
- // fallback: shallow walk under entry/src (skip resources/build/oh_modules)
109
- const entrySrc = path.join(repoAbs, "entry", "src");
110
- if (fs.existsSync(entrySrc)) {
111
- return walkSources(entrySrc, 8, 3);
112
- }
113
- return [];
130
+ // Fallback: walk the whole repo, skipping noise. Deep enough for multi-module layouts.
131
+ return walkSources(repoAbs, LIMIT, 8);
132
+ }
133
+ /** True for config/test/build noise files we don't want as "entry" examples. */
134
+ function isNoiseFile(name) {
135
+ const lower = name.toLowerCase();
136
+ if (lower.startsWith("hvigorfile"))
137
+ return true;
138
+ if (lower.endsWith(".test.ts") || lower.endsWith(".test.ets"))
139
+ return true;
140
+ if (lower === "build-profile.ts")
141
+ return true;
142
+ if (lower === "oh-package.json5.ts")
143
+ return true;
144
+ return false;
114
145
  }
146
+ /** Rank: pages/ > view/components > Index.ets > other .ets > .ts. Lower = better. */
147
+ function fileRank(fullPath) {
148
+ const norm = fullPath.replace(/\\/g, "/").toLowerCase();
149
+ if (norm.includes("/pages/"))
150
+ return 0;
151
+ if (norm.includes("/view/") || norm.includes("/views/"))
152
+ return 1;
153
+ if (norm.includes("/components/"))
154
+ return 2;
155
+ if (norm.endsWith("/index.ets"))
156
+ return 3;
157
+ if (norm.endsWith(".ets"))
158
+ return 4;
159
+ return 5; // .ts
160
+ }
161
+ /** Recursively collect .ets/.ts source files under dir, skipping noise dirs/files. */
115
162
  function listDirFiles(dir, exts, limit) {
116
- let ents;
117
- try {
118
- ents = fs.readdirSync(dir);
119
- }
120
- catch {
121
- return [];
122
- }
123
163
  const out = [];
124
- for (const name of ents.sort()) {
125
- if (exts.some((e) => name.endsWith(e))) {
126
- out.push(path.join(dir, name));
127
- if (out.length >= limit)
128
- break;
164
+ const visit = (d) => {
165
+ if (out.length >= 64)
166
+ return; // collect a pool then rank+trim
167
+ let ents;
168
+ try {
169
+ ents = fs.readdirSync(d, { withFileTypes: true });
129
170
  }
130
- }
131
- return out;
171
+ catch {
172
+ return;
173
+ }
174
+ for (const e of ents) {
175
+ if (e.isDirectory()) {
176
+ if (SKIP_DIRS.has(e.name))
177
+ continue;
178
+ visit(path.join(d, e.name));
179
+ }
180
+ else if (e.isFile() && exts.some((x) => e.name.endsWith(x)) && !isNoiseFile(e.name)) {
181
+ out.push(path.join(d, e.name));
182
+ }
183
+ }
184
+ };
185
+ visit(dir);
186
+ out.sort((a, b) => fileRank(a) - fileRank(b) || a.localeCompare(b));
187
+ return out.slice(0, limit);
132
188
  }
133
- const SKIP_DIRS = new Set(["resources", "build", "oh_modules", "node_modules", ".git", ".cxx", "cpp", "libs"]);
189
+ const SKIP_DIRS = new Set([
190
+ "resources", "build", "oh_modules", "node_modules", ".git", ".cxx",
191
+ "cpp", "libs", "rawfile", "media", "drawable", "element",
192
+ "test", "ohosTest", "testrunner", ".test", ".hvigor", ".idea",
193
+ "commons", "hvigor", "scripts",
194
+ ]);
134
195
  function walkSources(dir, limit, maxDepth) {
135
196
  const out = [];
136
197
  const visit = (d, depth) => {
137
- if (out.length >= limit || depth > maxDepth)
198
+ if (out.length >= 64 || depth > maxDepth)
138
199
  return;
139
200
  let ents;
140
201
  try {
@@ -144,20 +205,19 @@ function walkSources(dir, limit, maxDepth) {
144
205
  return;
145
206
  }
146
207
  for (const e of ents) {
147
- if (out.length >= limit)
148
- return;
149
208
  if (e.isDirectory()) {
150
209
  if (SKIP_DIRS.has(e.name))
151
210
  continue;
152
211
  visit(path.join(d, e.name), depth + 1);
153
212
  }
154
- else if (e.isFile() && (e.name.endsWith(".ets") || e.name.endsWith(".ts"))) {
213
+ else if (e.isFile() && (e.name.endsWith(".ets") || e.name.endsWith(".ts")) && !isNoiseFile(e.name)) {
155
214
  out.push(path.join(d, e.name));
156
215
  }
157
216
  }
158
217
  };
159
218
  visit(dir, 0);
160
- return out;
219
+ out.sort((a, b) => fileRank(a) - fileRank(b) || a.localeCompare(b));
220
+ return out.slice(0, limit);
161
221
  }
162
222
  /* ------------------------------------------------------------------ *
163
223
  * Tool 4: list_by_topic
package/dist/search.js CHANGED
@@ -58,6 +58,10 @@ export function search(store, query, limit = 8) {
58
58
  score += scoreAgainst(qTf, tokenize(meta.title), 5);
59
59
  score += scoreAgainst(qTf, tokenize(meta.subtitle), 3);
60
60
  score += scoreAgainst(qTf, tokenize(meta.topic), 2);
61
+ // All markdown headings — section-level signal, covers full doc (not just first 200 lines).
62
+ score += scoreAgainst(qTf, tokenize(meta.headings), 3);
63
+ // README intro of associated repos — rich signal (APIs used, what it does).
64
+ score += scoreAgainst(qTf, tokenize(meta.readmeDigest), 2);
61
65
  // Body scan (first ~200 lines) for recall. Lazy-read per candidate doc.
62
66
  const bodyScore = scanBody(store.docsDir, meta.docId, qTf, 200);
63
67
  score += bodyScore;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harmonyos-best-practices-mcp",
3
- "version": "0.1.0",
4
- "description": "MCP server exposing HarmonyOS best-practices docs (452) + reference code (186 repos) for retrieval during development.",
3
+ "version": "0.2.0",
4
+ "description": "MCP server exposing HarmonyOS best-practices docs + reference code for retrieval during development.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {