bc-observatory 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 +8 -1
- package/dist/embed.js +133 -0
- package/dist/server.js +93 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bc-observatory (MCP server)
|
|
2
2
|
|
|
3
3
|
[BC Observatory](https://waldo1001.github.io/waldo.BCObservatory/) for agents: an agent-first, cross-referenced knowledge base of
|
|
4
|
-
Microsoft Dynamics 365 Business Central (Learn topic hubs, AL objects extracted from the code for BC28-30, country localizations,
|
|
4
|
+
Microsoft Dynamics 365 Business Central (Learn topic hubs, AL objects extracted from the code for BC28-30 with history back to BC23, country localizations,
|
|
5
5
|
Microsoft 365 roadmap features, videos and community posts), every claim with a trust tier and evidence.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
@@ -16,4 +16,11 @@ The server downloads the site's search index (cached by hash in `~/.cache/bc-obs
|
|
|
16
16
|
markdown. `BC_OBSERVATORY_LOCAL=<checkout of waldo1001/waldo.BCObservatory>` reads everything from disk instead;
|
|
17
17
|
`BC_OBSERVATORY_SITE` points at another deployment.
|
|
18
18
|
|
|
19
|
+
`search` is hybrid: keywords plus meaning, so "who is allowed to see what" finds the permission sets. Meaning comes from
|
|
20
|
+
static embeddings ([model2vec](https://github.com/MinishLab/model2vec) `minishlab/potion-base-8M`, MIT) computed locally in
|
|
21
|
+
plain JavaScript; the 30 MB model is downloaded once on the first search, pinned by revision and checked by SHA-256. Pass
|
|
22
|
+
`mode: "keyword"` for exact terms such as an object name. `BC_OBSERVATORY_EMBEDDINGS=0` turns it off, and
|
|
23
|
+
`BC_OBSERVATORY_MODEL_DIR=<dir with config.json, tokenizer.json, model.safetensors>` uses a model on disk (offline). Without a
|
|
24
|
+
model, search is keyword-only and says so.
|
|
25
|
+
|
|
19
26
|
MIT. Content: see the repository's CONTENT-NOTICE.md (Microsoft content attributed; community content derived only).
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export const MAX_TOKENS = 512;
|
|
2
|
+
/** tokenizer.json (WordPiece), model.safetensors (one F32 tensor) and config.json into a model. */
|
|
3
|
+
export function parseModel(tokenizerJson, safetensors, config = {}) {
|
|
4
|
+
const t = JSON.parse(tokenizerJson);
|
|
5
|
+
if (t.model.type !== "WordPiece")
|
|
6
|
+
throw new Error(`unsupported tokenizer model ${t.model.type}`);
|
|
7
|
+
const vocab = new Map(Object.entries(t.model.vocab));
|
|
8
|
+
const view = new DataView(safetensors.buffer, safetensors.byteOffset, safetensors.byteLength);
|
|
9
|
+
const headerLen = Number(view.getBigUint64(0, true));
|
|
10
|
+
const header = JSON.parse(new TextDecoder().decode(safetensors.subarray(8, 8 + headerLen)));
|
|
11
|
+
const name = Object.keys(header).find((k) => k !== "__metadata__");
|
|
12
|
+
if (!name)
|
|
13
|
+
throw new Error("safetensors file has no tensor");
|
|
14
|
+
const { dtype, shape, data_offsets: [start, end] } = header[name];
|
|
15
|
+
if (dtype !== "F32" || shape.length !== 2)
|
|
16
|
+
throw new Error(`unsupported tensor ${name}: ${dtype} ${shape.join("x")}`);
|
|
17
|
+
// copy into an aligned buffer: the data starts at 8 + headerLen, which need not be a multiple of 4
|
|
18
|
+
const bytes = safetensors.slice(8 + headerLen + start, 8 + headerLen + end);
|
|
19
|
+
const matrix = new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
|
|
20
|
+
if (matrix.length !== shape[0] * shape[1])
|
|
21
|
+
throw new Error("tensor size does not match its shape");
|
|
22
|
+
const unk = t.model.unk_token !== undefined ? (vocab.get(t.model.unk_token) ?? null) : null;
|
|
23
|
+
return { dims: shape[1], vocab, unk, matrix, normalize: config.normalize !== false, maxWordChars: t.model.max_input_chars_per_word ?? 100, prefix: t.model.continuing_subword_prefix ?? "##" };
|
|
24
|
+
}
|
|
25
|
+
const CJK = [[0x4e00, 0x9fff], [0x3400, 0x4dbf], [0x20000, 0x2a6df], [0x2a700, 0x2b73f], [0x2b740, 0x2b81f], [0x2b820, 0x2ceaf], [0xf900, 0xfaff], [0x2f800, 0x2fa1f]];
|
|
26
|
+
const isCjk = (cp) => CJK.some(([a, b]) => cp >= a && cp <= b);
|
|
27
|
+
const isPunct = (ch, cp) => (cp >= 33 && cp <= 47) || (cp >= 58 && cp <= 64) || (cp >= 91 && cp <= 96) || (cp >= 123 && cp <= 126) || /\p{P}/u.test(ch);
|
|
28
|
+
/** BertNormalizer: clean text, CJK ideographs spaced out, accents stripped, lowercased. */
|
|
29
|
+
export function normalize(text) {
|
|
30
|
+
let out = "";
|
|
31
|
+
for (const ch of text) {
|
|
32
|
+
const cp = ch.codePointAt(0);
|
|
33
|
+
if (cp === 0 || cp === 0xfffd)
|
|
34
|
+
continue;
|
|
35
|
+
if (ch === "\t" || ch === "\n" || ch === "\r" || /\s/u.test(ch)) {
|
|
36
|
+
out += " ";
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (/[\p{Cc}\p{Cf}]/u.test(ch))
|
|
40
|
+
continue;
|
|
41
|
+
out += isCjk(cp) ? ` ${ch} ` : ch;
|
|
42
|
+
}
|
|
43
|
+
return out.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
/** BertPreTokenizer: words split on whitespace, every punctuation character a word of its own. */
|
|
46
|
+
export function preTokenize(text) {
|
|
47
|
+
const words = [];
|
|
48
|
+
let cur = "";
|
|
49
|
+
for (const ch of text) {
|
|
50
|
+
if (/\s/u.test(ch)) {
|
|
51
|
+
if (cur)
|
|
52
|
+
words.push(cur);
|
|
53
|
+
cur = "";
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (isPunct(ch, ch.codePointAt(0))) {
|
|
57
|
+
if (cur)
|
|
58
|
+
words.push(cur);
|
|
59
|
+
words.push(ch);
|
|
60
|
+
cur = "";
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
cur += ch;
|
|
64
|
+
}
|
|
65
|
+
if (cur)
|
|
66
|
+
words.push(cur);
|
|
67
|
+
return words;
|
|
68
|
+
}
|
|
69
|
+
/** Token ids of a text: no special tokens, no [UNK], at most MAX_TOKENS. */
|
|
70
|
+
export function tokenize(m, text) {
|
|
71
|
+
const ids = [];
|
|
72
|
+
for (const word of preTokenize(normalize(text))) {
|
|
73
|
+
const chars = [...word];
|
|
74
|
+
if (chars.length > m.maxWordChars)
|
|
75
|
+
continue; // the whole word would be [UNK], which is dropped
|
|
76
|
+
const pieces = [];
|
|
77
|
+
let start = 0, bad = false;
|
|
78
|
+
while (start < chars.length) {
|
|
79
|
+
let end = chars.length, found;
|
|
80
|
+
while (start < end) {
|
|
81
|
+
const sub = (start > 0 ? m.prefix : "") + chars.slice(start, end).join("");
|
|
82
|
+
found = m.vocab.get(sub);
|
|
83
|
+
if (found !== undefined)
|
|
84
|
+
break;
|
|
85
|
+
end--;
|
|
86
|
+
}
|
|
87
|
+
if (found === undefined) {
|
|
88
|
+
bad = true;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
pieces.push(found);
|
|
92
|
+
start = end;
|
|
93
|
+
}
|
|
94
|
+
if (!bad)
|
|
95
|
+
for (const id of pieces)
|
|
96
|
+
if (id !== m.unk)
|
|
97
|
+
ids.push(id);
|
|
98
|
+
if (ids.length >= MAX_TOKENS)
|
|
99
|
+
return ids.slice(0, MAX_TOKENS);
|
|
100
|
+
}
|
|
101
|
+
return ids;
|
|
102
|
+
}
|
|
103
|
+
/** The text's vector: mean of its tokens' rows, L2-normalized (all zeros for a text with no known token). */
|
|
104
|
+
export function embed(m, text) {
|
|
105
|
+
const v = new Float32Array(m.dims);
|
|
106
|
+
const ids = tokenize(m, text);
|
|
107
|
+
if (!ids.length)
|
|
108
|
+
return v;
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
const off = id * m.dims;
|
|
111
|
+
for (let d = 0; d < m.dims; d++)
|
|
112
|
+
v[d] += m.matrix[off + d];
|
|
113
|
+
}
|
|
114
|
+
for (let d = 0; d < m.dims; d++)
|
|
115
|
+
v[d] /= ids.length;
|
|
116
|
+
if (m.normalize) {
|
|
117
|
+
let n = 0;
|
|
118
|
+
for (let d = 0; d < m.dims; d++)
|
|
119
|
+
n += v[d] * v[d];
|
|
120
|
+
n = Math.sqrt(n);
|
|
121
|
+
if (n > 0)
|
|
122
|
+
for (let d = 0; d < m.dims; d++)
|
|
123
|
+
v[d] /= n;
|
|
124
|
+
}
|
|
125
|
+
return v;
|
|
126
|
+
}
|
|
127
|
+
/** Dot product: the cosine similarity of two normalized vectors. */
|
|
128
|
+
export function dot(a, b, bOffset = 0) {
|
|
129
|
+
let s = 0;
|
|
130
|
+
for (let d = 0; d < a.length; d++)
|
|
131
|
+
s += a[d] * b[bOffset + d];
|
|
132
|
+
return s;
|
|
133
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* BC_OBSERVATORY_SITE to point at another deployment.
|
|
9
9
|
*
|
|
10
10
|
* Tools: search, ls, cat, get_object, diff_object, localization, whats_new, blog_footprint, feedback.
|
|
11
|
+
*
|
|
12
|
+
* Search is hybrid (D63): MiniSearch keywords fused with static embeddings (model2vec potion-base-8M, MIT) by
|
|
13
|
+
* reciprocal rank, so "client" finds Customer. The model (30 MB) is downloaded once on the first search, pinned by
|
|
14
|
+
* revision and checked by SHA-256, into ~/.cache/bc-observatory/models/. BC_OBSERVATORY_EMBEDDINGS=0 turns it off;
|
|
15
|
+
* BC_OBSERVATORY_MODEL_DIR uses a model on disk (offline, tests). Without a model, search is keyword-only.
|
|
11
16
|
*/
|
|
12
17
|
import { createHash } from "node:crypto";
|
|
13
18
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
@@ -18,7 +23,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
18
23
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
24
|
import MiniSearch from "minisearch";
|
|
20
25
|
import { z } from "zod";
|
|
21
|
-
|
|
26
|
+
import { dot, embed, parseModel } from "./embed.js";
|
|
27
|
+
const VERSION = "0.2.0";
|
|
22
28
|
const SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
|
|
23
29
|
const LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
|
|
24
30
|
const REPO = "https://github.com/waldo1001/waldo.BCObservatory";
|
|
@@ -85,15 +91,94 @@ async function loadIndex() {
|
|
|
85
91
|
const url = (path) => `${SITE}${path}/`;
|
|
86
92
|
const line = (r) => `- ${r.title} [${r.type}${r.tier ? `, ${r.tier}` : ""}${r.date ? `, ${r.date}` : ""}] path=${r.path}\n ${r.summary}`;
|
|
87
93
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
94
|
+
// ---------------------------------------------------------------------------------------------- embeddings (D63)
|
|
95
|
+
const MODEL = {
|
|
96
|
+
repo: "minishlab/potion-base-8M", revision: "bf8b056651a2c21b8d2565580b8569da283cab23",
|
|
97
|
+
sha256: {
|
|
98
|
+
"config.json": "2a6ac0e9aaa356a68a5688070db78fc3a464fefe85d2f06a1905ce3718687553",
|
|
99
|
+
"tokenizer.json": "e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50",
|
|
100
|
+
"model.safetensors": "f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2",
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const MODEL_DIR = process.env.BC_OBSERVATORY_MODEL_DIR ? resolve(process.env.BC_OBSERVATORY_MODEL_DIR) : null;
|
|
104
|
+
// a local checkout is for offline use and tests: no download there unless a model directory is named
|
|
105
|
+
const EMBEDDINGS = process.env.BC_OBSERVATORY_EMBEDDINGS !== "0" && (!LOCAL || !!MODEL_DIR);
|
|
106
|
+
let model = null;
|
|
107
|
+
let modelError = EMBEDDINGS ? null : "turned off";
|
|
108
|
+
let vectors = null;
|
|
109
|
+
async function loadModel() {
|
|
110
|
+
if (model || modelError)
|
|
111
|
+
return model;
|
|
112
|
+
try {
|
|
113
|
+
let dir = MODEL_DIR;
|
|
114
|
+
if (!dir) {
|
|
115
|
+
dir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", "models", `potion-base-8M@${MODEL.revision.slice(0, 12)}`);
|
|
116
|
+
mkdirSync(dir, { recursive: true });
|
|
117
|
+
for (const [file, sha] of Object.entries(MODEL.sha256)) {
|
|
118
|
+
const p = join(dir, file);
|
|
119
|
+
if (existsSync(p))
|
|
120
|
+
continue;
|
|
121
|
+
const res = await fetch(`https://huggingface.co/${MODEL.repo}/resolve/${MODEL.revision}/${file}`, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
|
|
122
|
+
if (!res.ok)
|
|
123
|
+
throw new Error(`HTTP ${res.status} for ${file}`);
|
|
124
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
125
|
+
if (createHash("sha256").update(buf).digest("hex") !== sha)
|
|
126
|
+
throw new Error(`${file} does not match its pinned SHA-256`);
|
|
127
|
+
writeFileSync(p, buf);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
model = parseModel(readFileSync(join(dir, "tokenizer.json"), "utf8"), new Uint8Array(readFileSync(join(dir, "model.safetensors"))), JSON.parse(readFileSync(join(dir, "config.json"), "utf8")));
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
modelError = String(e.message).slice(0, 160);
|
|
134
|
+
}
|
|
135
|
+
return model;
|
|
136
|
+
}
|
|
137
|
+
/** One vector per page (title, summary, tags), rebuilt when the index reloads: 22k pages take about 0.3 s. */
|
|
138
|
+
function pageVectors(m) {
|
|
139
|
+
if (vectors && vectors.at === loadedAt)
|
|
140
|
+
return vectors.data;
|
|
141
|
+
const data = new Float32Array(pages.length * m.dims);
|
|
142
|
+
pages.forEach((p, i) => data.set(embed(m, [p.title, p.summary, ...(p.tags ?? [])].filter(Boolean).join(". ")), i * m.dims));
|
|
143
|
+
vectors = { at: loadedAt, data };
|
|
144
|
+
return data;
|
|
145
|
+
}
|
|
146
|
+
/** Reciprocal rank fusion of ranked lists of paths (k = 60). */
|
|
147
|
+
export function fuse(lists, k = 60) {
|
|
148
|
+
const score = new Map();
|
|
149
|
+
for (const list of lists)
|
|
150
|
+
list.forEach((p, i) => score.set(p, (score.get(p) ?? 0) + 1 / (k + i + 1)));
|
|
151
|
+
return [...score].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([p]) => p);
|
|
152
|
+
}
|
|
88
153
|
// ---------------------------------------------------------------------------------------------- tools
|
|
89
154
|
export async function toolSearch(a) {
|
|
90
155
|
await loadIndex();
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
}).slice(0,
|
|
156
|
+
const keep = (r) => (!a.type || r.type === a.type) && (!a.tier || r.tier === a.tier) && (!a.system || r.system === a.system);
|
|
157
|
+
const limit = a.limit ?? 10, mode = a.mode ?? "hybrid", POOL = 50;
|
|
158
|
+
const keyword = mode === "semantic" ? [] : search.search(a.query, { filter: (r) => keep(r) }).slice(0, POOL).map((r) => r.path);
|
|
159
|
+
let semantic = [], note = "";
|
|
160
|
+
if (mode !== "keyword") {
|
|
161
|
+
const m = await loadModel();
|
|
162
|
+
if (m) {
|
|
163
|
+
const q = embed(m, a.query), data = pageVectors(m);
|
|
164
|
+
if (q.some((x) => x !== 0)) {
|
|
165
|
+
const scored = [];
|
|
166
|
+
pages.forEach((p, i) => { if (keep(p))
|
|
167
|
+
scored.push([dot(q, data, i * m.dims), i]); });
|
|
168
|
+
semantic = scored.sort((x, y) => y[0] - x[0]).slice(0, POOL).map(([, i]) => pages[i].path);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (mode === "semantic")
|
|
172
|
+
note = ` (semantic search unavailable: ${modelError}; keyword results instead)`;
|
|
173
|
+
if (!m && mode === "semantic")
|
|
174
|
+
semantic = search.search(a.query, { filter: (r) => keep(r) }).slice(0, POOL).map((r) => r.path);
|
|
175
|
+
}
|
|
176
|
+
const byPath = new Map(pages.map((p) => [p.path, p]));
|
|
177
|
+
const hits = fuse([keyword, semantic].filter((l) => l.length)).slice(0, limit).map((p) => byPath.get(p)).filter(Boolean);
|
|
94
178
|
if (!hits.length)
|
|
95
179
|
return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.`;
|
|
96
|
-
|
|
180
|
+
const how = semantic.length && keyword.length ? "keyword + semantic" : semantic.length ? "semantic" : "keyword";
|
|
181
|
+
return `${hits.length} results, ${how}${note} (read one with cat(path)):\n${hits.map(line).join("\n")}`;
|
|
97
182
|
}
|
|
98
183
|
export async function toolLs(a) {
|
|
99
184
|
await loadIndex();
|
|
@@ -135,7 +220,7 @@ export async function toolDiffObject(a) {
|
|
|
135
220
|
diff = JSON.parse(await dataFile(`code/diffs/version/${a.from}__${a.to}.json`));
|
|
136
221
|
}
|
|
137
222
|
catch {
|
|
138
|
-
return `No version diff ${a.from} -> ${a.to}. Diffs exist between consecutive
|
|
223
|
+
return `No version diff ${a.from} -> ${a.to}. Diffs exist between consecutive majors from BC23 to BC30 (23 -> 24, ..., 29 -> 30); a changed member lists only what differs, as [from, to].`;
|
|
139
224
|
}
|
|
140
225
|
const o = diff.objects.find((x) => x.key === key);
|
|
141
226
|
if (!o)
|
|
@@ -174,8 +259,8 @@ export function createServer() {
|
|
|
174
259
|
const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
|
|
175
260
|
instructions: "BC Observatory: an agent-first knowledge base of Microsoft Dynamics 365 Business Central (Learn hubs, AL objects from the code for BC28-30, localizations, roadmap features, videos, community posts). Every page carries a trust tier (official = Microsoft, community = everyone else) and a review state: say which tier a claim comes from. Never invent AL object ids or version numbers: look them up with get_object. Start with search(), read pages with cat(path), browse with ls(path).",
|
|
176
261
|
});
|
|
177
|
-
server.registerTool("search", { title: "Search the knowledge base", description: "
|
|
178
|
-
inputSchema: { query: z.string(), type: z.string().optional(), tier: z.string().optional(), system: z.string().optional(), limit: z.number().int().min(1).max(50).optional() } }, async (a) => text(await toolSearch(a)));
|
|
262
|
+
server.registerTool("search", { title: "Search the knowledge base", description: "Search every page (title, summary, tags). Hybrid by default: keywords plus meaning, so a synonym or a paraphrase still finds the page; mode 'keyword' for exact terms such as an object name, 'semantic' for meaning only. Filter by type (topic, feature, object, localization, video, post), tier (official, community) or galaxy system (finance, sales, development, ...).",
|
|
263
|
+
inputSchema: { query: z.string(), type: z.string().optional(), tier: z.string().optional(), system: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), mode: z.enum(["hybrid", "keyword", "semantic"]).optional() } }, async (a) => text(await toolSearch(a)));
|
|
179
264
|
server.registerTool("ls", { title: "List pages", description: "Browse the page tree, e.g. ls('objects/table') or ls('localizations'); empty path lists the sections.", inputSchema: { path: z.string().optional() } }, async (a) => text(await toolLs(a)));
|
|
180
265
|
server.registerTool("cat", { title: "Read a page", description: "The full markdown of a page (frontmatter with evidence and links, then the body), by its path from search or ls.", inputSchema: { path: z.string() } }, async (a) => text(await toolCat(a)));
|
|
181
266
|
server.registerTool("get_object", { title: "Look up an AL object", description: "An AL object of W1 or a Microsoft first-party app by type and id or exact name, e.g. ('table', '18') or ('codeunit', 'Sales-Post'): fields, keys, events, public procedures, obsolete state, versions, countries that replace it, Learn pages.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bc-observatory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for BC Observatory: an agent-first, cross-referenced knowledge base of Microsoft Dynamics 365 Business Central (Learn, code, roadmap, videos, community blogs).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|