@openbiox/bizard-mcp 0.0.0-stage → 0.1.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/README.md +91 -2
- package/dist/adapt-tools.d.ts +3 -0
- package/dist/adapt-tools.js +45 -0
- package/dist/adapt.d.ts +12 -0
- package/dist/adapt.js +77 -0
- package/dist/bundle.d.ts +4 -0
- package/dist/bundle.js +35 -0
- package/dist/citation.d.ts +6 -0
- package/dist/citation.js +34 -0
- package/dist/contribute-tools.d.ts +3 -0
- package/dist/contribute-tools.js +41 -0
- package/dist/contribute.d.ts +44 -0
- package/dist/contribute.js +136 -0
- package/dist/entry.d.ts +17 -0
- package/dist/entry.js +48 -0
- package/dist/http.d.ts +9 -0
- package/dist/http.js +109 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +74 -0
- package/dist/resources.d.ts +3 -0
- package/dist/resources.js +36 -0
- package/dist/results.d.ts +13 -0
- package/dist/results.js +6 -0
- package/dist/search.d.ts +21 -0
- package/dist/search.js +64 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +115 -0
- package/dist/snapshot/bundle.json +170377 -0
- package/dist/snapshot/llms.txt +892 -0
- package/dist/tutorial-tools.d.ts +10 -0
- package/dist/tutorial-tools.js +38 -0
- package/dist/tutorial.d.ts +31 -0
- package/dist/tutorial.js +53 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.js +1 -0
- package/dist/update-check.d.ts +24 -0
- package/dist/update-check.js +49 -0
- package/dist/usage.d.ts +10 -0
- package/dist/usage.js +44 -0
- package/package.json +37 -4
package/dist/http.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
const RATE_CAPACITY = 60; // requests/min per client IP
|
|
4
|
+
const RATE_INTERVAL_MS = 60_000;
|
|
5
|
+
const BODY_MAX_BYTES = 1_000_000; // JSON-RPC messages are small; reject larger uploads
|
|
6
|
+
/** Trust X-Forwarded-For for client keying (spec §6.2 前置反代). Off by default:
|
|
7
|
+
* without a proxy in front, clients could spoof fresh buckets. Read per
|
|
8
|
+
* request so a config change takes effect without a restart. */
|
|
9
|
+
function trustProxy() {
|
|
10
|
+
const v = process.env.BIZARD_TRUST_PROXY;
|
|
11
|
+
return v === "1" || v === "true";
|
|
12
|
+
}
|
|
13
|
+
/** Client identity for rate limiting: the IP that just handed the request to
|
|
14
|
+
* the trusted proxy (rightmost X-Forwarded-For hop) when behind one, else the
|
|
15
|
+
* socket address. The leftmost hop is client-controlled and spoofable. */
|
|
16
|
+
function clientIp(req) {
|
|
17
|
+
if (trustProxy()) {
|
|
18
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
19
|
+
if (typeof forwarded === "string" && forwarded.length > 0) {
|
|
20
|
+
const hops = forwarded.split(",").map((h) => h.trim()).filter(Boolean);
|
|
21
|
+
if (hops.length > 0)
|
|
22
|
+
return hops[hops.length - 1];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Stateless Streamable HTTP endpoint (spec §6.2: public anonymous access).
|
|
29
|
+
* The factory builds one McpServer per POST — the SDK allows a single
|
|
30
|
+
* transport per server instance, so sharing one across concurrent requests
|
|
31
|
+
* throws "Already connected to a transport" (and kills the process via an
|
|
32
|
+
* unhandled rejection). Per-IP token-bucket rate limiting.
|
|
33
|
+
*/
|
|
34
|
+
export function startHttpServer(makeServer, port) {
|
|
35
|
+
const buckets = new Map();
|
|
36
|
+
const http = createServer((req, res) => {
|
|
37
|
+
const ip = clientIp(req);
|
|
38
|
+
if (!allow(ip, buckets)) {
|
|
39
|
+
res.writeHead(429, { "Retry-After": "30" });
|
|
40
|
+
res.end(JSON.stringify({ error: "rate limited" }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (req.method === "POST" && req.url === "/mcp") {
|
|
44
|
+
handlePost(makeServer(), req, res).catch((e) => {
|
|
45
|
+
console.error(`bizard-mcp HTTP: request failed: ${e.message}`);
|
|
46
|
+
if (res.headersSent)
|
|
47
|
+
return;
|
|
48
|
+
// Oversized uploads are the client's doing — 413, not a server fault.
|
|
49
|
+
const status = e instanceof BodyTooLargeError ? 413 : 500;
|
|
50
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
51
|
+
res.end(JSON.stringify({ error: status === 413 ? "request body too large" : "internal error" }));
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
res.writeHead(404).end();
|
|
56
|
+
});
|
|
57
|
+
http.listen(port, () => {
|
|
58
|
+
console.error(`bizard-mcp HTTP listening on :${port} (Streamable HTTP, anonymous, rate-limited)`);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function handlePost(server, req, res) {
|
|
62
|
+
// One stateless transport per request — each POST gets a fresh transport.
|
|
63
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
64
|
+
res.on("close", () => transport.close());
|
|
65
|
+
await server.connect(transport);
|
|
66
|
+
const body = await readBody(req);
|
|
67
|
+
await transport.handleRequest(req, res, body);
|
|
68
|
+
}
|
|
69
|
+
async function readBody(req) {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let size = 0;
|
|
72
|
+
for await (const chunk of req) {
|
|
73
|
+
size += chunk.length;
|
|
74
|
+
if (size > BODY_MAX_BYTES)
|
|
75
|
+
throw new BodyTooLargeError();
|
|
76
|
+
chunks.push(chunk);
|
|
77
|
+
}
|
|
78
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw || "{}");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
class BodyTooLargeError extends Error {
|
|
87
|
+
}
|
|
88
|
+
function allow(ip, buckets) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
// Opportunistic eviction: a public endpoint sees one-off IPs (spoofed or
|
|
91
|
+
// scanned); without pruning, the bucket map grows without bound.
|
|
92
|
+
if (buckets.size >= RATE_CAPACITY) {
|
|
93
|
+
for (const [key, b] of buckets) {
|
|
94
|
+
if (now - b.last > RATE_INTERVAL_MS)
|
|
95
|
+
buckets.delete(key);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const b = buckets.get(ip) ?? { tokens: RATE_CAPACITY, last: now };
|
|
99
|
+
const elapsed = now - b.last;
|
|
100
|
+
b.tokens = Math.min(RATE_CAPACITY, b.tokens + (elapsed / RATE_INTERVAL_MS) * RATE_CAPACITY);
|
|
101
|
+
b.last = now;
|
|
102
|
+
if (b.tokens < 1) {
|
|
103
|
+
buckets.set(ip, b);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
b.tokens -= 1;
|
|
107
|
+
buckets.set(ip, b);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export function registerPrompts(server) {
|
|
3
|
+
server.registerPrompt("recommend", {
|
|
4
|
+
title: "Recommend a chart",
|
|
5
|
+
description: "Clarify the user's visualization goal and recommend Bizard charts.",
|
|
6
|
+
argsSchema: { goal: z.string().describe("What the user wants to visualize") },
|
|
7
|
+
}, async ({ goal }) => ({
|
|
8
|
+
messages: [{
|
|
9
|
+
role: "user", content: {
|
|
10
|
+
type: "text", text: `The user wants to visualize: "${goal}".
|
|
11
|
+
|
|
12
|
+
1. If the goal is ambiguous, ask at most 2 clarifying questions (data shape, message to convey).
|
|
13
|
+
2. Call bizard_search with 2-3 keyword variants (EN and zh).
|
|
14
|
+
3. Compare the top hits on: data types required, message emphasis, aesthetics.
|
|
15
|
+
4. Recommend ONE chart plus 2 alternatives, with tutorial URLs from the results.
|
|
16
|
+
5. Remind the user that code should be adapted to their data (offer the adapt prompt).`,
|
|
17
|
+
},
|
|
18
|
+
}],
|
|
19
|
+
}));
|
|
20
|
+
server.registerPrompt("adapt", {
|
|
21
|
+
title: "Adapt tutorial code",
|
|
22
|
+
description: "Drive data-aware adaptation of a Bizard tutorial to the user's dataset.",
|
|
23
|
+
argsSchema: {
|
|
24
|
+
chartId: z.string().describe("Chart id from bizard_search"),
|
|
25
|
+
language: z.enum(["r", "python", "julia"]).default("r"),
|
|
26
|
+
},
|
|
27
|
+
}, async ({ chartId, language }) => ({
|
|
28
|
+
messages: [{
|
|
29
|
+
role: "user", content: {
|
|
30
|
+
type: "text", text: `Adapt Bizard chart "${chartId}" to my data using ${language}.
|
|
31
|
+
|
|
32
|
+
1. Call bizard_adapt_guidance for "${chartId}" (${language}) and follow its five sections in order.
|
|
33
|
+
2. FIRST inspect my data (str/df.info/head) — do not assume column names.
|
|
34
|
+
3. Rewrite the tutorial code from bizard_get_code minimally: only parameter and column changes.
|
|
35
|
+
4. Run the adapted code, show the plot, and self-check against the guidance's failure modes.
|
|
36
|
+
5. If a step fails, switch to the troubleshoot flow before improvising.`,
|
|
37
|
+
},
|
|
38
|
+
}],
|
|
39
|
+
}));
|
|
40
|
+
server.registerPrompt("troubleshoot", {
|
|
41
|
+
title: "Troubleshoot reproduction",
|
|
42
|
+
description: "Classify and fix a chart reproduction failure.",
|
|
43
|
+
argsSchema: {
|
|
44
|
+
chartId: z.string().describe("Chart id whose reproduction failed"),
|
|
45
|
+
error: z.string().max(4000).describe("The error output"),
|
|
46
|
+
},
|
|
47
|
+
}, async ({ chartId, error }) => ({
|
|
48
|
+
messages: [{
|
|
49
|
+
role: "user", content: {
|
|
50
|
+
type: "text", text: `Reproducing Bizard chart "${chartId}" failed with:
|
|
51
|
+
|
|
52
|
+
${error}
|
|
53
|
+
|
|
54
|
+
Classify the failure: (a) data shape/content, (b) package/dependency, (c) code/syntax.
|
|
55
|
+
Then: for (a) call bizard_adapt_guidance and fix the data prep; for (b) check the tutorial's Setup list; for (c) diff against bizard_get_code output. Re-run after each fix; report what changed.`,
|
|
56
|
+
},
|
|
57
|
+
}],
|
|
58
|
+
}));
|
|
59
|
+
server.registerPrompt("contribute", {
|
|
60
|
+
title: "Contribute back",
|
|
61
|
+
description: "Turn the current finding into a validated Bizard contribution.",
|
|
62
|
+
}, async () => ({
|
|
63
|
+
messages: [{
|
|
64
|
+
role: "user", content: {
|
|
65
|
+
type: "text", text: `Help me contribute to Bizard.
|
|
66
|
+
|
|
67
|
+
1. Ask which contribution type: new-tutorial (+10), fix (+3), translation (+2), gallery-image (+3).
|
|
68
|
+
2. Draft the content following Template/contribution_0323.md conventions (YAML author, ## Setup, executable code blocks).
|
|
69
|
+
3. Run bizard_contribute_check on the draft; fix all issues.
|
|
70
|
+
4. Call bizard_contribute_submit. If it returns a draft + deep-link (no credentials here), open the link and confirm submission.`,
|
|
71
|
+
},
|
|
72
|
+
}],
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { tutorialPayload } from "./tutorial.js";
|
|
6
|
+
import { NO_BUNDLE_ERROR } from "./server.js";
|
|
7
|
+
const LLMS_TXT_PATH = join(dirname(fileURLToPath(import.meta.url)), "snapshot", "llms.txt");
|
|
8
|
+
function textResult(text) {
|
|
9
|
+
return { contents: [{ uri: "", text }] };
|
|
10
|
+
}
|
|
11
|
+
export function registerResources(server, getBundle) {
|
|
12
|
+
server.registerResource("catalog", "bizard://catalog", { title: "Chart catalog", description: "Compact JSON of all Bizard charts (id, name, category, URLs).", mimeType: "application/json" }, () => {
|
|
13
|
+
const bundle = getBundle();
|
|
14
|
+
if (!bundle)
|
|
15
|
+
throw new Error(NO_BUNDLE_ERROR);
|
|
16
|
+
const compact = bundle.charts.map((c) => ({
|
|
17
|
+
id: c.id, name: c.name, level1: c.level1, level2: c.level2, tutorialUrl: c.tutorialUrl,
|
|
18
|
+
}));
|
|
19
|
+
return textResult(JSON.stringify(compact, null, 2));
|
|
20
|
+
});
|
|
21
|
+
server.registerResource("tutorial", new ResourceTemplate("bizard://tutorial/{chartId}", { list: undefined }), { title: "Tutorial payload", description: "Full tutorial payload for one chart.", mimeType: "application/json" }, (uri, variables) => {
|
|
22
|
+
const bundle = getBundle();
|
|
23
|
+
if (!bundle)
|
|
24
|
+
throw new Error(NO_BUNDLE_ERROR);
|
|
25
|
+
const payload = tutorialPayload(bundle, String(variables.chartId));
|
|
26
|
+
return { contents: [{ uri: uri.href, text: JSON.stringify(payload, null, 2), mimeType: "application/json" }] };
|
|
27
|
+
});
|
|
28
|
+
server.registerResource("llms-txt", "bizard://llms-txt", { title: "llms.txt", description: "Bizard llms.txt overview (present only in built snapshots).", mimeType: "text/plain" }, () => {
|
|
29
|
+
try {
|
|
30
|
+
return textResult(readFileSync(LLMS_TXT_PATH, "utf-8"));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error("bizard-mcp: llms.txt not found — run `npm run build:bundle` to generate the snapshot, then rebuild.");
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
package/dist/results.js
ADDED
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Bundle, Chart } from "./types.js";
|
|
2
|
+
export interface SearchHit {
|
|
3
|
+
chart: Chart;
|
|
4
|
+
score: number;
|
|
5
|
+
}
|
|
6
|
+
/** Tokenize a query into lowercase terms; CJK chars become individual terms. */
|
|
7
|
+
export declare function tokenize(query: string): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Expand query terms via the bundle's synonym table (both directions:
|
|
10
|
+
* canonical → synonyms and synonyms → canonical) for cross-language reach.
|
|
11
|
+
*/
|
|
12
|
+
export declare function expandTerms(query: string, synonyms: Record<string, string[]>): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Score charts against expanded terms: name matches weigh most, then type,
|
|
15
|
+
* then description/levels. Deterministic; no LLM.
|
|
16
|
+
*/
|
|
17
|
+
export declare function searchCharts(bundle: Bundle, query: string, opts?: {
|
|
18
|
+
category?: string;
|
|
19
|
+
}): SearchHit[];
|
|
20
|
+
/** Zero-hit fallback: top-3 charts from the same Level1 category as best type guess. */
|
|
21
|
+
export declare function adjacentSuggestions(bundle: Bundle, query: string, limit?: number): Chart[];
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Tokenize a query into lowercase terms; CJK chars become individual terms. */
|
|
2
|
+
export function tokenize(query) {
|
|
3
|
+
return query
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.split(/[\s,,。;;:、!!??()()]+/)
|
|
6
|
+
.map((t) => t.trim())
|
|
7
|
+
.filter((t) => t.length > 0);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Expand query terms via the bundle's synonym table (both directions:
|
|
11
|
+
* canonical → synonyms and synonyms → canonical) for cross-language reach.
|
|
12
|
+
*/
|
|
13
|
+
export function expandTerms(query, synonyms) {
|
|
14
|
+
const terms = new Set();
|
|
15
|
+
for (const raw of tokenize(query)) {
|
|
16
|
+
terms.add(raw);
|
|
17
|
+
for (const [canon, syns] of Object.entries(synonyms)) {
|
|
18
|
+
const all = [canon, ...syns].map((s) => s.toLowerCase());
|
|
19
|
+
if (all.includes(raw)) {
|
|
20
|
+
for (const s of all)
|
|
21
|
+
terms.add(s);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return [...terms];
|
|
26
|
+
}
|
|
27
|
+
/** Null-safe lowercase-includes: a chart field lost to a malformed CSV row never crashes scoring. */
|
|
28
|
+
const contains = (field, term) => typeof field === "string" && field.toLowerCase().includes(term);
|
|
29
|
+
/**
|
|
30
|
+
* Score charts against expanded terms: name matches weigh most, then type,
|
|
31
|
+
* then description/levels. Deterministic; no LLM.
|
|
32
|
+
*/
|
|
33
|
+
export function searchCharts(bundle, query, opts) {
|
|
34
|
+
const terms = expandTerms(query, bundle.keywords?.synonyms ?? {});
|
|
35
|
+
const hits = [];
|
|
36
|
+
for (const chart of bundle.charts) {
|
|
37
|
+
if (opts?.category && opts.category !== chart.level2)
|
|
38
|
+
continue;
|
|
39
|
+
let score = 0;
|
|
40
|
+
for (const term of terms) {
|
|
41
|
+
if (contains(chart.name, term))
|
|
42
|
+
score += 5;
|
|
43
|
+
if (contains(chart.nameZh, term))
|
|
44
|
+
score += 5;
|
|
45
|
+
if (contains(chart.type, term))
|
|
46
|
+
score += 3;
|
|
47
|
+
if (contains(chart.description, term))
|
|
48
|
+
score += 2;
|
|
49
|
+
if (contains(chart.level2, term))
|
|
50
|
+
score += 3;
|
|
51
|
+
}
|
|
52
|
+
if (score > 0)
|
|
53
|
+
hits.push({ chart, score });
|
|
54
|
+
}
|
|
55
|
+
return hits.sort((a, b) => b.score - a.score || a.chart.id.localeCompare(b.chart.id));
|
|
56
|
+
}
|
|
57
|
+
/** Zero-hit fallback: top-3 charts from the same Level1 category as best type guess. */
|
|
58
|
+
export function adjacentSuggestions(bundle, query, limit = 3) {
|
|
59
|
+
const terms = expandTerms(query, bundle.keywords?.synonyms ?? {});
|
|
60
|
+
const level1 = bundle.charts.find((c) => terms.some((t) => contains(c.level2, t) || contains(c.type, t)))?.level1;
|
|
61
|
+
if (!level1)
|
|
62
|
+
return bundle.charts.slice(0, limit);
|
|
63
|
+
return bundle.charts.filter((c) => c.level1 === level1).slice(0, limit);
|
|
64
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { Bundle } from "./types.js";
|
|
3
|
+
import type { CredentialEnv } from "./contribute.js";
|
|
4
|
+
import type { NoticeQueue } from "./update-check.js";
|
|
5
|
+
export declare const NO_BUNDLE_ERROR: string;
|
|
6
|
+
/** Anything that can hand back a Bundle or null (snapshot today, ETag re-check later). */
|
|
7
|
+
export interface BundleHolder {
|
|
8
|
+
get(): Bundle | null;
|
|
9
|
+
}
|
|
10
|
+
export declare function createBizardServer(source: Bundle | BundleHolder | null, notices?: NoticeQueue, opts?: {
|
|
11
|
+
credentialEnv?: CredentialEnv;
|
|
12
|
+
}): McpServer;
|
|
13
|
+
export declare function searchTool(bundle: Bundle, query: string, language: "en" | "zh", category?: string, limit?: number, note?: string | null): {
|
|
14
|
+
content: {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { adjacentSuggestions, searchCharts } from "./search.js";
|
|
4
|
+
import { registerTutorialTools } from "./tutorial-tools.js";
|
|
5
|
+
import { registerAdaptTools } from "./adapt-tools.js";
|
|
6
|
+
import { registerContributeTools } from "./contribute-tools.js";
|
|
7
|
+
import { registerPrompts } from "./prompts.js";
|
|
8
|
+
import { registerResources } from "./resources.js";
|
|
9
|
+
import { updateNotice } from "./update-check.js";
|
|
10
|
+
import { recordUsage } from "./usage.js";
|
|
11
|
+
const SEARCH_LIMIT_MAX = 20;
|
|
12
|
+
const QUERY_MAX_CHARS = 4000;
|
|
13
|
+
export const NO_BUNDLE_ERROR = "bizard-mcp: bundle snapshot unavailable. The maintainer must run " +
|
|
14
|
+
"`npm run build:bundle` (or CI) to generate src/snapshot/bundle.json, then rebuild.";
|
|
15
|
+
function json(value, note) {
|
|
16
|
+
const payload = note === null || note === undefined
|
|
17
|
+
? { ...value }
|
|
18
|
+
: { note, ...value };
|
|
19
|
+
return {
|
|
20
|
+
content: [{
|
|
21
|
+
type: "text",
|
|
22
|
+
text: JSON.stringify(payload, null, 2),
|
|
23
|
+
}],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function bundleError() {
|
|
27
|
+
return {
|
|
28
|
+
isError: true,
|
|
29
|
+
content: [{ type: "text", text: NO_BUNDLE_ERROR }],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Constant server identity — never derives from bundle state. */
|
|
33
|
+
const SERVER_IDENTITY = { name: "bizard-mcp", version: "0.1.1" };
|
|
34
|
+
export function createBizardServer(source, notices, opts) {
|
|
35
|
+
const getBundle = () => {
|
|
36
|
+
if (source === null)
|
|
37
|
+
return null;
|
|
38
|
+
if ("get" in source && typeof source.get === "function")
|
|
39
|
+
return source.get();
|
|
40
|
+
return source;
|
|
41
|
+
};
|
|
42
|
+
// One-time SFL notice (spec §3.4): consumed by the first tool result after an update swap.
|
|
43
|
+
const drainNotice = () => (notices ? updateNotice(notices) : null);
|
|
44
|
+
// Local-only, anonymous usage feedback (README Privacy): fire-and-forget — a
|
|
45
|
+
// logging failure must never fail the tool call it observes. Each handler
|
|
46
|
+
// passes its event only after the payload was built successfully.
|
|
47
|
+
const track = (ev) => {
|
|
48
|
+
void recordUsage(ev).catch(() => { });
|
|
49
|
+
};
|
|
50
|
+
const server = new McpServer(SERVER_IDENTITY);
|
|
51
|
+
server.registerTool("bizard_search", {
|
|
52
|
+
title: "Search Bizard chart tutorials",
|
|
53
|
+
description: "Search the Bizard atlas (800+ biomedical chart tutorials, EN/zh) by keyword. Returns chart cards with tutorial URLs and image previews.",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
query: z.string().max(QUERY_MAX_CHARS).describe("Search keywords, English or Chinese"),
|
|
56
|
+
language: z.enum(["en", "zh"]).default("en").describe("Preferred result language"),
|
|
57
|
+
category: z.string().optional().describe("Filter by category (from bizard_list_categories)"),
|
|
58
|
+
limit: z.number().int().min(1).max(SEARCH_LIMIT_MAX).default(5),
|
|
59
|
+
},
|
|
60
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
61
|
+
}, async ({ query, language, category, limit }) => {
|
|
62
|
+
const bundle = getBundle();
|
|
63
|
+
if (!bundle)
|
|
64
|
+
return bundleError();
|
|
65
|
+
track({ event: "search", language, query });
|
|
66
|
+
return searchTool(bundle, query, language, category, limit, drainNotice());
|
|
67
|
+
});
|
|
68
|
+
server.registerTool("bizard_list_categories", {
|
|
69
|
+
title: "List Bizard categories",
|
|
70
|
+
description: "List all Bizard chart categories (bilingual) with per-category chart counts.",
|
|
71
|
+
inputSchema: {},
|
|
72
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
73
|
+
}, async () => {
|
|
74
|
+
const bundle = getBundle();
|
|
75
|
+
if (!bundle)
|
|
76
|
+
return bundleError();
|
|
77
|
+
return categoriesTool(bundle, drainNotice());
|
|
78
|
+
});
|
|
79
|
+
const lazyBundle = {
|
|
80
|
+
get: () => {
|
|
81
|
+
const bundle = getBundle();
|
|
82
|
+
if (!bundle)
|
|
83
|
+
throw new Error(NO_BUNDLE_ERROR);
|
|
84
|
+
return bundle;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
registerTutorialTools(server, lazyBundle, track);
|
|
88
|
+
registerAdaptTools(server, lazyBundle, track);
|
|
89
|
+
registerContributeTools(server, opts?.credentialEnv);
|
|
90
|
+
registerPrompts(server);
|
|
91
|
+
registerResources(server, getBundle);
|
|
92
|
+
return server;
|
|
93
|
+
}
|
|
94
|
+
export function searchTool(bundle, query, language, category, limit = 5, note = null) {
|
|
95
|
+
const hits = searchCharts(bundle, query, { category });
|
|
96
|
+
const nameOf = (c) => (language === "zh" ? c.nameZh ?? c.name : c.name);
|
|
97
|
+
const urlOf = (c) => (language === "zh" ? c.tutorialUrlZh ?? c.tutorialUrl : c.tutorialUrl);
|
|
98
|
+
const cards = hits.slice(0, limit).map(({ chart }) => ({
|
|
99
|
+
id: chart.id,
|
|
100
|
+
name: nameOf(chart),
|
|
101
|
+
type: chart.type,
|
|
102
|
+
imageUrl: chart.imageUrl,
|
|
103
|
+
tutorialUrl: urlOf(chart),
|
|
104
|
+
description: chart.description,
|
|
105
|
+
}));
|
|
106
|
+
if (cards.length > 0)
|
|
107
|
+
return json({ results: cards }, note);
|
|
108
|
+
const near = adjacentSuggestions(bundle, query).map((c) => ({
|
|
109
|
+
id: c.id, name: nameOf(c), tutorialUrl: urlOf(c),
|
|
110
|
+
}));
|
|
111
|
+
return json({ results: [], suggestions: near, note: note ?? "No exact matches. Nearby charts in the closest category:" });
|
|
112
|
+
}
|
|
113
|
+
function categoriesTool(bundle, note = null) {
|
|
114
|
+
return json(bundle.categories, note);
|
|
115
|
+
}
|