@mafdet/mcp 1.4.18 → 1.4.32

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.
@@ -1,4 +1,32 @@
1
1
  import { getJson, MafdetClientError } from "./client.js";
2
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
3
+ /**
4
+ * Split a query into terms. Whole-phrase matching alone made every multi-word
5
+ * question fail: "transcribe audio" appears verbatim on no page, so an agent
6
+ * asking the obvious question got nothing while the answer sat one word away.
7
+ *
8
+ * Chinese needs more than splitting on spaces, because it has none:
9
+ * "怎么转录音频" arrives as a single token that appears nowhere, and the docs are
10
+ * bilingual. Any run of CJK is therefore also broken into two-character
11
+ * shingles, which is enough to surface 转录 and 音频 out of that question. Some
12
+ * shingles are meaningless (么转); they simply match nothing and cost nothing,
13
+ * since ranking is by how many terms hit.
14
+ */
15
+ function terms(query) {
16
+ const out = [];
17
+ for (const token of query
18
+ .toLowerCase()
19
+ .split(/[^\p{L}\p{N}]+/u)
20
+ .filter((t) => t.length > 0)) {
21
+ out.push(token);
22
+ if (CJK.test(token) && token.length > 2) {
23
+ for (let i = 0; i + 2 <= token.length; i += 1) {
24
+ out.push(token.slice(i, i + 2));
25
+ }
26
+ }
27
+ }
28
+ return [...new Set(out)];
29
+ }
2
30
  let cache;
3
31
  async function loadIndex(docsUrl) {
4
32
  if (cache && cache.docsUrl === docsUrl) {
@@ -24,23 +52,46 @@ export async function searchDocs(input) {
24
52
  return [];
25
53
  }
26
54
  const base = input.docsBaseForUrl ?? input.docsUrl;
55
+ const words = terms(needle);
27
56
  const scored = [];
28
57
  for (const doc of docs) {
29
58
  const title = doc.title.toLowerCase();
59
+ const headings = (doc.headings ?? []).join(" ").toLowerCase();
30
60
  const text = doc.text.toLowerCase();
31
- // Title matches rank above body matches; skip non-matches entirely.
61
+ // The exact phrase still ranks hardest — someone who typed it means it.
32
62
  let score = 0;
33
63
  if (title.includes(needle))
34
- score += 2;
64
+ score += 20;
35
65
  if (text.includes(needle))
36
- score += 1;
66
+ score += 10;
67
+ // Then per term, so a question phrased in the reader's words still lands.
68
+ let matched = 0;
69
+ for (const word of words) {
70
+ let hit = 0;
71
+ if (title.includes(word))
72
+ hit += 2;
73
+ if (headings.includes(word))
74
+ hit += 1.5;
75
+ if (text.includes(word))
76
+ hit += 1;
77
+ if (hit > 0)
78
+ matched += 1;
79
+ score += hit;
80
+ }
37
81
  if (score > 0) {
38
- scored.push({ doc, score });
82
+ scored.push({ doc, score, matched });
39
83
  }
40
84
  }
85
+ // Pages carrying every term first. Falling back to partial matches rather
86
+ // than returning nothing: one unknown word in a question should not hide the
87
+ // page that answers the rest of it.
88
+ const complete = scored.filter((s) => s.matched === words.length);
89
+ const ranked = complete.length > 0 ? complete : scored;
41
90
  // Stable ordering: score desc, then id asc (docs are pre-sorted by id).
42
- scored.sort((a, b) => b.score - a.score || a.doc.id.localeCompare(b.doc.id));
43
- return scored.slice(0, input.limit).map(({ doc }) => ({
91
+ ranked.sort((a, b) => b.matched - a.matched ||
92
+ b.score - a.score ||
93
+ a.doc.id.localeCompare(b.doc.id));
94
+ return ranked.slice(0, input.limit).map(({ doc }) => ({
44
95
  title: doc.title,
45
96
  url: new URL(doc.url, base).toString(),
46
97
  summary: doc.summary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mafdet/mcp",
3
- "version": "1.4.18",
3
+ "version": "1.4.32",
4
4
  "description": "Mafdet AI MCP server — read-only account tools (models, balance, usage, subscription, docs) for Claude Code, Cursor, Codex, OpenCode, and Claude Desktop.",
5
5
  "type": "module",
6
6
  "license": "MIT",