bc-observatory 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/server.js +207 -0
- package/package.json +37 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric "waldo" Wauters
|
|
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
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
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,
|
|
5
|
+
Microsoft 365 roadmap features, videos and community posts), every claim with a trust tier and evidence.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
claude mcp add bc-observatory -- npx -y bc-observatory@latest
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or the Claude Code plugin (MCP server plus skills): `claude plugin marketplace add waldo1001/waldo.BCObservatory`.
|
|
12
|
+
|
|
13
|
+
Tools: `search`, `ls`, `cat`, `get_object`, `diff_object`, `localization`, `whats_new`, `blog_footprint`, `feedback`.
|
|
14
|
+
|
|
15
|
+
The server downloads the site's search index (cached by hash in `~/.cache/bc-observatory/`, refreshed daily) and reads pages as
|
|
16
|
+
markdown. `BC_OBSERVATORY_LOCAL=<checkout of waldo1001/waldo.BCObservatory>` reads everything from disk instead;
|
|
17
|
+
`BC_OBSERVATORY_SITE` points at another deployment.
|
|
18
|
+
|
|
19
|
+
MIT. Content: see the repository's CONTENT-NOTICE.md (Microsoft content attributed; community content derived only).
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bc-observatory MCP server (PLAN 4.7, D35): BC Observatory's knowledge base for agents, over stdio.
|
|
4
|
+
*
|
|
5
|
+
* Data: the site's search index (index-manifest.json + pages-<n>.json, cached by sha256 in
|
|
6
|
+
* ~/.cache/bc-observatory/<site hash>/, refreshed daily) and its markdown twins (<path>.md). Set
|
|
7
|
+
* BC_OBSERVATORY_LOCAL=<repo checkout> to read content/ and data/ from disk instead (offline, tests), or
|
|
8
|
+
* BC_OBSERVATORY_SITE to point at another deployment.
|
|
9
|
+
*
|
|
10
|
+
* Tools: search, ls, cat, get_object, diff_object, localization, whats_new, blog_footprint, feedback.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
+
import MiniSearch from "minisearch";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
const VERSION = "0.1.0";
|
|
22
|
+
const SITE = (process.env.BC_OBSERVATORY_SITE ?? "https://waldo1001.github.io/waldo.BCObservatory/").replace(/\/?$/, "/");
|
|
23
|
+
const LOCAL = process.env.BC_OBSERVATORY_LOCAL ? resolve(process.env.BC_OBSERVATORY_LOCAL) : null;
|
|
24
|
+
const REPO = "https://github.com/waldo1001/waldo.BCObservatory";
|
|
25
|
+
const DAY = 86_400_000;
|
|
26
|
+
// ---------------------------------------------------------------------------------------------- data access
|
|
27
|
+
const cacheDir = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "bc-observatory", createHash("sha256").update(SITE).digest("hex").slice(0, 12));
|
|
28
|
+
async function fetchText(url) {
|
|
29
|
+
const res = await fetch(url, { headers: { "user-agent": `bc-observatory-mcp/${VERSION}` } });
|
|
30
|
+
if (!res.ok)
|
|
31
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
32
|
+
return res.text();
|
|
33
|
+
}
|
|
34
|
+
/** A data file (index manifest, shard, diff): from disk in local mode, else from the site. */
|
|
35
|
+
async function dataFile(rel) {
|
|
36
|
+
if (LOCAL)
|
|
37
|
+
return readFileSync(join(LOCAL, "data", rel), "utf8");
|
|
38
|
+
return fetchText(`${SITE}${rel}`);
|
|
39
|
+
}
|
|
40
|
+
/** A page's markdown: content/<path>.md locally, the site's markdown twin otherwise. */
|
|
41
|
+
async function pageMarkdown(path) {
|
|
42
|
+
const clean = path.replace(/^\/+|\/+$/g, "").replace(/\.md$/, "");
|
|
43
|
+
if (LOCAL) {
|
|
44
|
+
const p = join(LOCAL, "content", `${clean}.md`);
|
|
45
|
+
if (!existsSync(p))
|
|
46
|
+
throw new Error(`no page at ${clean}`);
|
|
47
|
+
return readFileSync(p, "utf8");
|
|
48
|
+
}
|
|
49
|
+
return fetchText(`${SITE}${clean}.md`);
|
|
50
|
+
}
|
|
51
|
+
let pages = [];
|
|
52
|
+
let search = null;
|
|
53
|
+
let loadedAt = 0;
|
|
54
|
+
/** Index shards by sha256: cached on disk, downloaded only when the manifest names a new hash. */
|
|
55
|
+
async function loadIndex() {
|
|
56
|
+
if (search && Date.now() - loadedAt < DAY)
|
|
57
|
+
return;
|
|
58
|
+
const manifest = JSON.parse(await dataFile("index/index-manifest.json"));
|
|
59
|
+
const all = [];
|
|
60
|
+
if (!LOCAL)
|
|
61
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
62
|
+
for (const s of manifest.shards) {
|
|
63
|
+
const cached = join(cacheDir, `${s.sha256}.json`);
|
|
64
|
+
let text;
|
|
65
|
+
if (!LOCAL && existsSync(cached))
|
|
66
|
+
text = readFileSync(cached, "utf8");
|
|
67
|
+
else {
|
|
68
|
+
text = await dataFile(`index/${s.file}`);
|
|
69
|
+
if (!LOCAL)
|
|
70
|
+
writeFileSync(cached, text);
|
|
71
|
+
}
|
|
72
|
+
all.push(...JSON.parse(text));
|
|
73
|
+
}
|
|
74
|
+
const ms = new MiniSearch({
|
|
75
|
+
idField: "path", fields: ["title", "summary", "tagText", "path"],
|
|
76
|
+
storeFields: ["path", "type", "title", "summary", "tier", "system", "date", "object_type", "object_id", "app", "country", "source", "status", "obsolete"],
|
|
77
|
+
extractField: (doc, f) => (f === "tagText" ? (doc.tags ?? []).join(" ") : doc[f]),
|
|
78
|
+
searchOptions: { boost: { title: 3, tagText: 1.5 }, prefix: true, fuzzy: 0.15 },
|
|
79
|
+
});
|
|
80
|
+
ms.addAll(all);
|
|
81
|
+
pages = all;
|
|
82
|
+
search = ms;
|
|
83
|
+
loadedAt = Date.now();
|
|
84
|
+
}
|
|
85
|
+
const url = (path) => `${SITE}${path}/`;
|
|
86
|
+
const line = (r) => `- ${r.title} [${r.type}${r.tier ? `, ${r.tier}` : ""}${r.date ? `, ${r.date}` : ""}] path=${r.path}\n ${r.summary}`;
|
|
87
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
88
|
+
// ---------------------------------------------------------------------------------------------- tools
|
|
89
|
+
export async function toolSearch(a) {
|
|
90
|
+
await loadIndex();
|
|
91
|
+
const hits = search.search(a.query, {
|
|
92
|
+
filter: (r) => (!a.type || r.type === a.type) && (!a.tier || r.tier === a.tier) && (!a.system || r.system === a.system),
|
|
93
|
+
}).slice(0, a.limit ?? 10);
|
|
94
|
+
if (!hits.length)
|
|
95
|
+
return `No pages match "${a.query}". Try fewer words, or ls("") to browse sections.`;
|
|
96
|
+
return `${hits.length} results (read one with cat(path)):\n${hits.map(line).join("\n")}`;
|
|
97
|
+
}
|
|
98
|
+
export async function toolLs(a) {
|
|
99
|
+
await loadIndex();
|
|
100
|
+
const prefix = (a.path ?? "").replace(/^\/+|\/+$/g, "");
|
|
101
|
+
const under = pages.filter((p) => !prefix || p.path === prefix || p.path.startsWith(`${prefix}/`));
|
|
102
|
+
if (!under.length)
|
|
103
|
+
return `Nothing under "${prefix}". Sections: ${[...new Set(pages.map((p) => p.path.split("/")[0]))].sort().join(", ")}.`;
|
|
104
|
+
const depth = prefix ? prefix.split("/").length : 0;
|
|
105
|
+
const dirs = new Map(), files = [];
|
|
106
|
+
for (const p of under) {
|
|
107
|
+
const parts = p.path.split("/");
|
|
108
|
+
if (parts.length === depth + 1)
|
|
109
|
+
files.push(p);
|
|
110
|
+
else if (parts.length > depth + 1)
|
|
111
|
+
dirs.set(parts.slice(0, depth + 1).join("/"), (dirs.get(parts.slice(0, depth + 1).join("/")) ?? 0) + 1);
|
|
112
|
+
}
|
|
113
|
+
const MAX = 200;
|
|
114
|
+
const out = [...[...dirs].sort().map(([d, n]) => `${d}/ (${n} pages)`), ...files.slice(0, MAX).map((f) => `${f.path}: ${f.title}`)];
|
|
115
|
+
return `${out.join("\n")}${files.length > MAX ? `\n... and ${files.length - MAX} more pages; use search() to narrow down` : ""}`;
|
|
116
|
+
}
|
|
117
|
+
export async function toolCat(a) {
|
|
118
|
+
return pageMarkdown(a.path);
|
|
119
|
+
}
|
|
120
|
+
export async function toolGetObject(a) {
|
|
121
|
+
await loadIndex();
|
|
122
|
+
const type = a.type.toLowerCase().replace(/\s+/g, "");
|
|
123
|
+
const want = a.idOrName.trim().replace(/^"|"$/g, "");
|
|
124
|
+
const objs = pages.filter((p) => p.type === "object" && p.object_type === type);
|
|
125
|
+
const hit = /^\d+$/.test(want) ? objs.find((p) => p.object_id === Number(want))
|
|
126
|
+
: objs.find((p) => p.title.toLowerCase().endsWith(`"${want.toLowerCase()}"`));
|
|
127
|
+
if (!hit)
|
|
128
|
+
return `No ${type} "${want}" in W1 or Microsoft's first-party apps (BC28-30). Never invent object ids; country-only objects are listed on localization pages.`;
|
|
129
|
+
return pageMarkdown(hit.path);
|
|
130
|
+
}
|
|
131
|
+
export async function toolDiffObject(a) {
|
|
132
|
+
const key = `${a.type.toLowerCase()}/${a.id.toLowerCase()}`;
|
|
133
|
+
let diff;
|
|
134
|
+
try {
|
|
135
|
+
diff = JSON.parse(await dataFile(`code/diffs/version/${a.from}__${a.to}.json`));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return `No version diff ${a.from} -> ${a.to}. Diffs exist between consecutive snapshot versions (28 -> 29, 29 -> 30).`;
|
|
139
|
+
}
|
|
140
|
+
const o = diff.objects.find((x) => x.key === key);
|
|
141
|
+
if (!o)
|
|
142
|
+
return `${key} did not change between BC${a.from} and BC${a.to} (or is not a W1 object).`;
|
|
143
|
+
return `${key} "${o.name}" BC${a.from} -> BC${a.to}: ${o.change}\n${JSON.stringify(o, null, 1)}`;
|
|
144
|
+
}
|
|
145
|
+
export async function toolLocalization(a) {
|
|
146
|
+
return pageMarkdown(`localizations/${a.country.toLowerCase()}`);
|
|
147
|
+
}
|
|
148
|
+
export async function toolWhatsNew(a) {
|
|
149
|
+
await loadIndex();
|
|
150
|
+
const since = a.since.slice(0, 10);
|
|
151
|
+
const items = pages.filter((p) => p.date && p.date >= since && (!a.type || p.type === a.type)).sort((x, y) => y.date.localeCompare(x.date)).slice(0, a.limit ?? 30);
|
|
152
|
+
if (!items.length)
|
|
153
|
+
return `Nothing dated on or after ${since}.`;
|
|
154
|
+
return `${items.length} items since ${since}, newest first:\n${items.map(line).join("\n")}`;
|
|
155
|
+
}
|
|
156
|
+
export async function toolBlogFootprint(a) {
|
|
157
|
+
await loadIndex();
|
|
158
|
+
const items = pages.filter((p) => p.source === a.source).sort((x, y) => (y.date ?? "").localeCompare(x.date ?? ""));
|
|
159
|
+
if (!items.length)
|
|
160
|
+
return `No posts or videos from source "${a.source}" yet. Source ids are listed in ${REPO}/blob/main/sources.yaml.`;
|
|
161
|
+
const systems = new Map();
|
|
162
|
+
for (const p of items)
|
|
163
|
+
if (p.system)
|
|
164
|
+
systems.set(p.system, (systems.get(p.system) ?? 0) + 1);
|
|
165
|
+
return `${a.source}: ${items.length} items, ${items.at(-1).date ?? "?"} to ${items[0].date ?? "?"}. Systems: ${[...systems].sort((x, y) => y[1] - x[1]).map(([s, n]) => `${s} ${n}`).join(", ")}.\n${items.slice(0, 20).map(line).join("\n")}`;
|
|
166
|
+
}
|
|
167
|
+
export function toolFeedback(a) {
|
|
168
|
+
const title = `Feedback on ${a.path}`;
|
|
169
|
+
const body = `Page: ${url(a.path)}\n\n${a.message}\n\n(sent from the bc-observatory MCP server ${VERSION})`;
|
|
170
|
+
return `Open this link to file the issue (no sign-in through the agent): ${REPO}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}&labels=feedback`;
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------------------------- server
|
|
173
|
+
export function createServer() {
|
|
174
|
+
const server = new McpServer({ name: "bc-observatory", version: VERSION }, {
|
|
175
|
+
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
|
+
});
|
|
177
|
+
server.registerTool("search", { title: "Search the knowledge base", description: "Full-text search over every page (title, summary, tags). Filter by type (topic, feature, object, localization, video, post), tier (official, community) or galaxy system (finance, sales, development, ...).",
|
|
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)));
|
|
179
|
+
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
|
+
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
|
+
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.",
|
|
182
|
+
inputSchema: { type: z.string(), idOrName: z.string() } }, async (a) => text(await toolGetObject(a)));
|
|
183
|
+
server.registerTool("diff_object", { title: "What changed in an object", description: "Member-level changes of a W1 object between two consecutive versions (fields, events, procedures, keys, properties, obsolete state).",
|
|
184
|
+
inputSchema: { type: z.string(), id: z.string(), from: z.string(), to: z.string() } }, async (a) => text(await toolDiffObject(a)));
|
|
185
|
+
server.registerTool("localization", { title: "A country localization", description: "What a country layer (BE, NL, DE, ...) adds to or changes in W1, with its Learn local functionality hub.", inputSchema: { country: z.string().length(2) } }, async (a) => text(await toolLocalization(a)));
|
|
186
|
+
server.registerTool("whats_new", { title: "What is new", description: "Videos, posts and roadmap features dated on or after a date (YYYY-MM-DD), newest first.",
|
|
187
|
+
inputSchema: { since: z.string(), type: z.string().optional(), limit: z.number().int().min(1).max(100).optional() } }, async (a) => text(await toolWhatsNew(a)));
|
|
188
|
+
server.registerTool("blog_footprint", { title: "A source's footprint", description: "What a blog or channel (source id from sources.yaml, e.g. 'kauffmann-nl', 'yt-hougaard') covers: its posts or videos and the systems they touch.",
|
|
189
|
+
inputSchema: { source: z.string() } }, async (a) => text(await toolBlogFootprint(a)));
|
|
190
|
+
server.registerTool("feedback", { title: "Report a problem with a page", description: "Returns a prefilled GitHub issue link for a page (wrong fact, missing evidence, broken link). Nothing is sent by the agent.",
|
|
191
|
+
inputSchema: { path: z.string(), message: z.string() } }, async (a) => text(toolFeedback(a)));
|
|
192
|
+
return server;
|
|
193
|
+
}
|
|
194
|
+
/** Started as a program (npx, bin symlink, node dist/server.js, tsx src/server.ts) rather than imported by a test. */
|
|
195
|
+
function isMain() {
|
|
196
|
+
if (!process.argv[1])
|
|
197
|
+
return false;
|
|
198
|
+
try {
|
|
199
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (isMain()) {
|
|
206
|
+
await createServer().connect(new StdioServerTransport());
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bc-observatory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bc-observatory": "dist/server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/waldo1001/waldo.BCObservatory.git",
|
|
20
|
+
"directory": "packages/mcp"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://waldo1001.github.io/waldo.BCObservatory/",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"business-central",
|
|
26
|
+
"dynamics-365",
|
|
27
|
+
"al",
|
|
28
|
+
"model-context-protocol"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/server.js",
|
|
32
|
+
"start": "node dist/server.js"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
36
|
+
"minisearch": "^7.2.0",
|
|
37
|
+
"zod": "^4.6.5"
|
|
38
|
+
}
|
|
39
|
+
}
|