@openbiox/bizard-mcp 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/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/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @openbiox/bizard-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for
|
|
4
|
+
the [Bizard](https://bizard.app) biomedical visualization atlas. It serves the
|
|
5
|
+
full chart lifecycle over MCP: **query** (bilingual search across 800+ chart
|
|
6
|
+
tutorials), **use** (runnable code, dependencies, data URLs, and structured
|
|
7
|
+
adaptation guidance the host AI executes), **cite** (BibTeX/DOI metadata with
|
|
8
|
+
license notice), and **contribute** (template-checked issue/PR submission that
|
|
9
|
+
degrades gracefully by environment). Content comes from a bundle generated by
|
|
10
|
+
CI from the same sources as the website and `SKILL.md`, so all three never
|
|
11
|
+
drift. The server never executes plotting code — the host AI does.
|
|
12
|
+
|
|
13
|
+
## Quickstart (local, stdio)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @openbiox/bizard-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Register it with Claude Code:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
claude mcp add bizard -- npx @openbiox/bizard-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Works offline: an npm-shipped content snapshot backs everything up.
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | Purpose |
|
|
30
|
+
|------|---------|
|
|
31
|
+
| `bizard_search` | Bilingual weighted search over the chart gallery; adjacent-category suggestions on zero hits |
|
|
32
|
+
| `bizard_list_categories` | Category tree (12 Level-1 categories with Chinese/English aliases and counts) |
|
|
33
|
+
| `bizard_get_tutorial` | Structured tutorial content: sections, setup dependencies, data description, key parameters |
|
|
34
|
+
| `bizard_get_code` | Runnable tutorial code with package list, install guard, and example-data URLs |
|
|
35
|
+
| `bizard_adapt_guidance` | Data-shape checks, dependency prep, parameter mapping, failure modes, alternatives |
|
|
36
|
+
| `bizard_cite` | Bizard paper + tutorial citation (bibtex/text/json) with CC-BY-NC-4.0 license notice |
|
|
37
|
+
| `bizard_contribute_check` | Validate a draft against the contribution template; preview points (issue #4) |
|
|
38
|
+
| `bizard_contribute_submit` | Submit as issue/PR via user credentials, or degrade to draft + deep-link |
|
|
39
|
+
|
|
40
|
+
Prompts: `recommend`, `adapt`, `troubleshoot`, `contribute`.
|
|
41
|
+
Resources: `bizard://catalog`, `bizard://tutorial/{chartId}`, `bizard://llms-txt`.
|
|
42
|
+
|
|
43
|
+
## Deployment
|
|
44
|
+
|
|
45
|
+
One package, two transports:
|
|
46
|
+
|
|
47
|
+
- **Local (stdio, default)**: `npx @openbiox/bizard-mcp`. Full capability; contribution runs at level 1/2 when local credentials exist.
|
|
48
|
+
- **Hosted (Streamable HTTP)**: `npx @openbiox/bizard-mcp --http`. Port via `BIZARD_HTTP_PORT` (default 3000). Anonymous, stateless — each POST to `/mcp` gets a fresh transport. Per-IP token-bucket rate limit (60 req/min; 429 with `Retry-After`). Contribution runs at level 3 (no credentials on the server).
|
|
49
|
+
|
|
50
|
+
## Contribution credential degradation
|
|
51
|
+
|
|
52
|
+
`bizard_contribute_submit` probes the environment on every call and states
|
|
53
|
+
which level it used — never silently failing:
|
|
54
|
+
|
|
55
|
+
| Level | Condition | Behavior |
|
|
56
|
+
|-------|-----------|----------|
|
|
57
|
+
| 1 | `gh` CLI installed and logged in | Creates issue (or PR when `asPR`) as the user |
|
|
58
|
+
| 2 | No `gh`, but `GITHUB_TOKEN` set | GitHub API submission, limited by token scope |
|
|
59
|
+
| 3 | Neither (typical for hosted) | Returns formatted draft + pre-filled `issues/new?title=&body=` deep-link |
|
|
60
|
+
|
|
61
|
+
The server itself holds zero credentials. User free text is truncated
|
|
62
|
+
(single field ≤ 4000 chars) before being assembled into issue bodies.
|
|
63
|
+
|
|
64
|
+
## Privacy
|
|
65
|
+
|
|
66
|
+
Usage feedback is local-only, anonymous, and never networked:
|
|
67
|
+
|
|
68
|
+
- Appended to `~/.cache/bizard-mcp/usage-log.jsonl` as
|
|
69
|
+
`{ ts, event, chartId, language, queryHash }` — `queryHash` is the first
|
|
70
|
+
8 hex chars of the query's SHA-256 (irreversible).
|
|
71
|
+
- Set `BIZARD_USAGE_LOG=off` to disable entirely; set it to a path to relocate
|
|
72
|
+
the log.
|
|
73
|
+
- No server-side telemetry, ever.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm install
|
|
79
|
+
npm run build:bundle # regenerate src/snapshot/ (works from mcp/ — defaults are repo-root-relative)
|
|
80
|
+
npm test # vitest suite
|
|
81
|
+
npm run coverage # gate: statements ≥ 80%
|
|
82
|
+
npm run build # tsc + copy snapshot into dist/
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`npm run build:bundle` shells out to `../.github/scripts/build-bundle.py`,
|
|
86
|
+
whose CLI defaults resolve against the repository root regardless of the
|
|
87
|
+
current working directory. Run it from `mcp/` via the npm script, or run the
|
|
88
|
+
Python script from anywhere with an explicit `--out`. CI:
|
|
89
|
+
`.github/workflows/mcp-ci.yml` (Python bundle tests, node tests + coverage
|
|
90
|
+
gate, stdio smoke) and `.github/workflows/mcp-bundle.yml` (rebuilds
|
|
91
|
+
`files/mcp/bundle.json` + `llms.txt` on content pushes, which the site ships
|
|
92
|
+
via `_quarto.yml` resources).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { adaptGuidance } from "./adapt.js";
|
|
3
|
+
import { citationPayload } from "./citation.js";
|
|
4
|
+
import { jsonResult, errorResult } from "./results.js";
|
|
5
|
+
export function registerAdaptTools(server, bundle, track) {
|
|
6
|
+
server.registerTool("bizard_adapt_guidance", {
|
|
7
|
+
title: "Get chart adaptation guidance",
|
|
8
|
+
description: "Category-aware checklist for adapting a Bizard chart tutorial to the user's own data: data shape, dependencies, parameter mapping, failure modes, alternatives.",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
chartId: z.string().describe("Chart id from bizard_search"),
|
|
11
|
+
language: z.enum(["r", "python", "julia"]).describe("Target language for the adaptation"),
|
|
12
|
+
userDataContext: z.string().max(2000).optional()
|
|
13
|
+
.describe("Optional description of the user's data/task; echoed into the checklist"),
|
|
14
|
+
},
|
|
15
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
16
|
+
}, async ({ chartId, language, userDataContext }) => {
|
|
17
|
+
try {
|
|
18
|
+
const payload = adaptGuidance(bundle.get(), chartId, language, userDataContext);
|
|
19
|
+
track({ event: "adapt", chartId, language });
|
|
20
|
+
return jsonResult(payload);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return errorResult(e.message);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
server.registerTool("bizard_cite", {
|
|
27
|
+
title: "Cite Bizard",
|
|
28
|
+
description: "Citation for the Bizard paper and optionally one chart tutorial: BibTeX, paste-ready methods text, or structured JSON.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
chartId: z.string().optional().describe("Chart id to cite a specific tutorial (omitted = paper only)"),
|
|
31
|
+
paperOnly: z.boolean().default(false).describe("Cite only the Bizard paper, ignore chartId"),
|
|
32
|
+
format: z.enum(["bibtex", "text", "json"]).default("bibtex").describe("Citation format"),
|
|
33
|
+
},
|
|
34
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
35
|
+
}, async ({ chartId = null, paperOnly, format }) => {
|
|
36
|
+
try {
|
|
37
|
+
const result = citationPayload(bundle.get(), chartId, paperOnly, format);
|
|
38
|
+
track({ event: "cite", chartId: chartId ?? undefined });
|
|
39
|
+
return jsonResult(format === "json" ? result.json : result.text);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return errorResult(e.message);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
package/dist/adapt.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Bundle } from "./types.js";
|
|
2
|
+
export interface GuidanceSection {
|
|
3
|
+
title: string;
|
|
4
|
+
items: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface AdaptGuidance {
|
|
7
|
+
chartId: string;
|
|
8
|
+
language: string;
|
|
9
|
+
userDataContext: string | null;
|
|
10
|
+
sections: GuidanceSection[];
|
|
11
|
+
}
|
|
12
|
+
export declare function adaptGuidance(bundle: Bundle, chartId: string, language: "r" | "python" | "julia", userDataContext?: string): AdaptGuidance;
|
package/dist/adapt.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { findChart } from "./tutorial.js";
|
|
2
|
+
const FAILURE_MODES = {
|
|
3
|
+
Distribution: [
|
|
4
|
+
"Too few data points: violin/box plots need >= 5 values per group; check with nrow()/len() before plotting.",
|
|
5
|
+
"Factor levels missing: a group absent from the data drops silently — verify levels(factor_col) / unique(df.group).",
|
|
6
|
+
"Distribution summary checks: confirm the plotted variable's range and spread make sense for a distribution chart.",
|
|
7
|
+
],
|
|
8
|
+
Correlation: [
|
|
9
|
+
"Non-numeric columns passed to correlation: coerce or drop them first (types(df) / str(df)).",
|
|
10
|
+
"Perfect collinearity produces NaN coefficients — inspect the variance of each variable.",
|
|
11
|
+
],
|
|
12
|
+
default: [
|
|
13
|
+
"Wrong data shape (long vs wide): reshape (pivot_longer / melt / pd.melt) before plotting.",
|
|
14
|
+
"Package version drift: reinstall with the pinned versions from the Setup section.",
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
const PARAM_HINTS = {
|
|
18
|
+
r: [
|
|
19
|
+
"Map user columns onto aes(): replace example column names (e.g. aes(x = wt) → aes(x = your_col)).",
|
|
20
|
+
"Adjust scale_/theme_ options only after the base plot renders — change one layer at a time.",
|
|
21
|
+
],
|
|
22
|
+
python: [
|
|
23
|
+
"Replace example column names in the plotting call with your DataFrame columns.",
|
|
24
|
+
"Keep seaborn/matplotlib layer order: data → axes → styling.",
|
|
25
|
+
],
|
|
26
|
+
julia: [
|
|
27
|
+
"Replace example column symbols in the plotting macro with your table columns.",
|
|
28
|
+
"Keep Makie/Gadfly attribute names consistent with the loaded backend.",
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
const MAX_ALTERNATIVES = 3;
|
|
32
|
+
const DESCRIPTION_PREVIEW_CHARS = 80;
|
|
33
|
+
export function adaptGuidance(bundle, chartId, language, userDataContext) {
|
|
34
|
+
const chart = findChart(bundle, chartId);
|
|
35
|
+
if (!chart)
|
|
36
|
+
throw new Error(`Unknown chartId: ${chartId}`);
|
|
37
|
+
const failureModes = FAILURE_MODES[chart.level2] ?? FAILURE_MODES.default;
|
|
38
|
+
const alternatives = bundle.charts
|
|
39
|
+
.filter((c) => c.level1 === chart.level1 && c.id !== chart.id)
|
|
40
|
+
.slice(0, MAX_ALTERNATIVES)
|
|
41
|
+
.map((c) => `${c.name} (${c.tutorialUrl}) — ${c.description.slice(0, DESCRIPTION_PREVIEW_CHARS)}`);
|
|
42
|
+
return {
|
|
43
|
+
chartId: chart.id,
|
|
44
|
+
language,
|
|
45
|
+
userDataContext: userDataContext ?? null,
|
|
46
|
+
sections: [
|
|
47
|
+
{
|
|
48
|
+
title: "Data shape check",
|
|
49
|
+
items: [
|
|
50
|
+
`Before plotting, verify your data has the columns this ${chart.type} expects (long format unless stated otherwise).`,
|
|
51
|
+
"Run a structural self-check first: str(df) / df.info() / show(schema).",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
title: "Dependency preparation",
|
|
56
|
+
items: [
|
|
57
|
+
`Install exactly the packages listed in the tutorial Setup (${chart.codeRefs.join(", ") || "see tutorial"}).`,
|
|
58
|
+
"Use the tutorial's install-guard pattern: check-installed → install → library(), never bare install.packages at top level.",
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
title: "Parameter mapping",
|
|
63
|
+
items: PARAM_HINTS[language],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: "Common failure modes",
|
|
67
|
+
items: failureModes,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
title: "Alternatives",
|
|
71
|
+
items: alternatives.length > 0
|
|
72
|
+
? alternatives
|
|
73
|
+
: ["No same-category alternatives in the bundle; browse bizard_list_categories."],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/bundle.d.ts
ADDED
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const SNAPSHOT_PATH = join(dirname(fileURLToPath(import.meta.url)), "snapshot", "bundle.json");
|
|
5
|
+
/**
|
|
6
|
+
* Validate and load a bundle. Throws a descriptive error when the input
|
|
7
|
+
* is not a bundle-shaped object — the server falls back to the snapshot
|
|
8
|
+
* (or a startup failure) rather than serving garbage.
|
|
9
|
+
*/
|
|
10
|
+
/** Chart fields a row lost to a malformed CSV cell; null here crashes search. */
|
|
11
|
+
const REQUIRED_CHART_FIELDS = [
|
|
12
|
+
"name", "type", "level1", "level2", "tutorialUrl", "imageUrl", "description",
|
|
13
|
+
];
|
|
14
|
+
export function loadBundle(inline) {
|
|
15
|
+
const b = inline;
|
|
16
|
+
if (!b || typeof b !== "object" ||
|
|
17
|
+
!Array.isArray(b.charts) || b.charts.length === 0 ||
|
|
18
|
+
!b.charts.every((c) => typeof c?.id === "string" && c.id.length > 0 &&
|
|
19
|
+
REQUIRED_CHART_FIELDS.every((f) => typeof c[f] === "string" && c[f].length > 0)) ||
|
|
20
|
+
!Array.isArray(b.categories) ||
|
|
21
|
+
!b.meta || typeof b.meta !== "object" ||
|
|
22
|
+
!b.citation || typeof b.citation !== "object") {
|
|
23
|
+
throw new Error("Bundle missing or invalid: expected object with non-empty charts[], categories[], meta, citation");
|
|
24
|
+
}
|
|
25
|
+
return b;
|
|
26
|
+
}
|
|
27
|
+
/** Read the npm-shipped snapshot; null when absent (fresh checkout without a build). */
|
|
28
|
+
export function loadSnapshot() {
|
|
29
|
+
try {
|
|
30
|
+
return loadBundle(JSON.parse(readFileSync(SNAPSHOT_PATH, "utf-8")));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/citation.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { findChart } from "./tutorial.js";
|
|
2
|
+
const LICENSE_NOTE = "CC-BY-NC-4.0";
|
|
3
|
+
const METHODS_SENTENCE_EN = 'The figure was generated following the Bizard tutorial "{name}" ({url}); Bizard is licensed CC-BY-NC-4.0.';
|
|
4
|
+
const METHODS_SENTENCE_ZH = "该图参考 Bizard 教程「{name}」({url}) 复现;Bizard 采用 CC-BY-NC-4.0 许可。";
|
|
5
|
+
export function citationPayload(bundle, chartId, paperOnly, format) {
|
|
6
|
+
const cite = bundle.citation;
|
|
7
|
+
const wantsChart = chartId !== null && !paperOnly;
|
|
8
|
+
const chart = wantsChart ? findChart(bundle, chartId) : undefined;
|
|
9
|
+
if (wantsChart && !chart)
|
|
10
|
+
throw new Error(`Unknown chartId: ${chartId}`);
|
|
11
|
+
if (format === "json") {
|
|
12
|
+
return {
|
|
13
|
+
text: "",
|
|
14
|
+
json: {
|
|
15
|
+
paper: { doi: cite.paperDoi, url: cite.paperUrl, license: cite.license },
|
|
16
|
+
chart: chart ? { id: chart.id, name: chart.name, url: chart.tutorialUrl } : null,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (format === "bibtex") {
|
|
21
|
+
const text = chart
|
|
22
|
+
? `${cite.bibtex}\n% Chart: ${chart.name} — ${chart.tutorialUrl}\n% License: ${cite.license}`
|
|
23
|
+
: cite.bibtex;
|
|
24
|
+
return { text, json: null };
|
|
25
|
+
}
|
|
26
|
+
const text = chart
|
|
27
|
+
? fillMethods(METHODS_SENTENCE_EN, chart.name, chart.tutorialUrl)
|
|
28
|
+
+ "\n" + fillMethods(METHODS_SENTENCE_ZH, chart.name, chart.tutorialUrl)
|
|
29
|
+
: `Biomedical charts adapted from Bizard (${cite.paperUrl}), DOI ${cite.paperDoi}, license ${cite.license}.`;
|
|
30
|
+
return { text, json: null };
|
|
31
|
+
}
|
|
32
|
+
function fillMethods(template, name, url) {
|
|
33
|
+
return template.replaceAll("{name}", name).replaceAll("{url}", url);
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { checkContribution, submitContribution } from "./contribute.js";
|
|
3
|
+
import { jsonResult, errorResult } from "./results.js";
|
|
4
|
+
const USER_CONTEXT_MAX_CHARS = 2000;
|
|
5
|
+
export function registerContributeTools(server, credentialEnv) {
|
|
6
|
+
server.registerTool("bizard_contribute_check", {
|
|
7
|
+
title: "Check a contribution draft",
|
|
8
|
+
description: "Static checks on a draft tutorial/fix before submitting to Bizard: front matter, Setup section, executable code blocks; previews contribution points.",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
contributionType: z.enum(["new-tutorial", "fix", "translation", "gallery-image"]),
|
|
11
|
+
content: z.string().max(200_000).describe("Full draft content (markdown/QMD) to check"),
|
|
12
|
+
language: z.enum(["en", "zh"]),
|
|
13
|
+
},
|
|
14
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
15
|
+
}, async ({ contributionType, content, language }) => {
|
|
16
|
+
try {
|
|
17
|
+
return jsonResult(checkContribution({ contributionType, content, language }));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return errorResult(e.message);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
server.registerTool("bizard_contribute_submit", {
|
|
24
|
+
title: "Submit a contribution to Bizard",
|
|
25
|
+
description: "Submit a contribution as a GitHub issue. Degradation chain: gh CLI → GITHUB_TOKEN API → draft + deep-link for manual browser submission.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
contributionType: z.enum(["new-tutorial", "fix", "translation", "gallery-image"]),
|
|
28
|
+
title: z.string().max(200).describe("Issue title"),
|
|
29
|
+
body: z.string().max(100_000).describe("Issue body (markdown); over-long bodies are truncated"),
|
|
30
|
+
asPR: z.boolean().default(false).describe("Kept for future PR submission; issues only today"),
|
|
31
|
+
},
|
|
32
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
33
|
+
}, async ({ contributionType, title, body, asPR }) => {
|
|
34
|
+
try {
|
|
35
|
+
return jsonResult(await submitContribution({ contributionType, title, body, asPR }, credentialEnv ?? {}));
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return errorResult(e.message);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare const BODY_MAX_CHARS = 4000;
|
|
2
|
+
export interface ExecResult {
|
|
3
|
+
code: number;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
export type ExecFn = (cmd: string, args: string[]) => Promise<ExecResult>;
|
|
8
|
+
export interface CredentialEnv {
|
|
9
|
+
exec?: ExecFn;
|
|
10
|
+
githubToken?: string;
|
|
11
|
+
/** Overridable fetch for tests; defaults to globalThis.fetch. */
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
ghOwner?: string;
|
|
14
|
+
ghRepo?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CheckInput {
|
|
17
|
+
contributionType: "new-tutorial" | "fix" | "translation" | "gallery-image";
|
|
18
|
+
content: string;
|
|
19
|
+
language: "en" | "zh";
|
|
20
|
+
}
|
|
21
|
+
export interface CheckResult {
|
|
22
|
+
pass: boolean;
|
|
23
|
+
issues: string[];
|
|
24
|
+
checklist: string[];
|
|
25
|
+
pointsPreview: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SubmitInput {
|
|
28
|
+
contributionType: CheckInput["contributionType"];
|
|
29
|
+
title: string;
|
|
30
|
+
body: string;
|
|
31
|
+
asPR: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface SubmitResult {
|
|
34
|
+
level: 1 | 2 | 3;
|
|
35
|
+
submitted: boolean;
|
|
36
|
+
url: string | null;
|
|
37
|
+
draft: string | null;
|
|
38
|
+
deepLink: string | null;
|
|
39
|
+
bodyTruncated: boolean;
|
|
40
|
+
note: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function checkContribution(input: CheckInput): CheckResult;
|
|
43
|
+
export declare function detectCredentialLevel(env: CredentialEnv): Promise<1 | 2 | 3>;
|
|
44
|
+
export declare function submitContribution(input: SubmitInput, env: CredentialEnv): Promise<SubmitResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFile = promisify(execFileCb);
|
|
4
|
+
export const BODY_MAX_CHARS = 4000;
|
|
5
|
+
const TITLE_MAX_CHARS = 120;
|
|
6
|
+
const ERROR_SNIPPET_CHARS = 200;
|
|
7
|
+
const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const POINTS = {
|
|
9
|
+
"new-tutorial": "+10",
|
|
10
|
+
"fix": "+3",
|
|
11
|
+
"translation": "+2",
|
|
12
|
+
"gallery-image": "+3",
|
|
13
|
+
};
|
|
14
|
+
/** Only labels that exist in openbiox/Bizard; other types submit unlabeled for maintainer triage. */
|
|
15
|
+
const ISSUE_LABELS = {
|
|
16
|
+
"new-tutorial": "新增图表-新教程",
|
|
17
|
+
};
|
|
18
|
+
export function checkContribution(input) {
|
|
19
|
+
const issues = [];
|
|
20
|
+
const c = input.content;
|
|
21
|
+
if (!/^---\n[\s\S]*?\n---/.test(c))
|
|
22
|
+
issues.push("Missing YAML front matter (--- … ---).");
|
|
23
|
+
if (!/^author:/m.test(c))
|
|
24
|
+
issues.push("YAML front matter lacks author field.");
|
|
25
|
+
if (input.contributionType === "new-tutorial" && !/^## Setup/m.test(c)) {
|
|
26
|
+
issues.push("New tutorials must include the fixed '## Setup' section (see Template/).");
|
|
27
|
+
}
|
|
28
|
+
if (input.contributionType === "new-tutorial" && !/```\{(r|python|julia)/.test(c)) {
|
|
29
|
+
issues.push("New tutorials must contain at least one executable code block.");
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
pass: issues.length === 0,
|
|
33
|
+
issues,
|
|
34
|
+
checklist: [
|
|
35
|
+
"Followed Template/contribution_0323.md structure",
|
|
36
|
+
"Figures carry fig-alt text",
|
|
37
|
+
`Contribution points reference: ${POINTS[input.contributionType]} (issue #4 rules)`,
|
|
38
|
+
],
|
|
39
|
+
pointsPreview: POINTS[input.contributionType],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function detectCredentialLevel(env) {
|
|
43
|
+
const exec = execOf(env);
|
|
44
|
+
const gh = await exec("gh", ["auth", "status"]);
|
|
45
|
+
if (gh.code === 0)
|
|
46
|
+
return 1;
|
|
47
|
+
if (env.githubToken)
|
|
48
|
+
return 2;
|
|
49
|
+
return 3;
|
|
50
|
+
}
|
|
51
|
+
export async function submitContribution(input, env) {
|
|
52
|
+
const owner = env.ghOwner ?? "openbiox";
|
|
53
|
+
const repo = env.ghRepo ?? "Bizard";
|
|
54
|
+
const truncated = input.body.length > BODY_MAX_CHARS;
|
|
55
|
+
const body = truncated ? input.body.slice(0, BODY_MAX_CHARS) + "\n\n[Truncated]" : input.body;
|
|
56
|
+
const level = await detectCredentialLevel(env);
|
|
57
|
+
if (level === 1)
|
|
58
|
+
return submitViaGh(input, env, owner, repo, body, truncated);
|
|
59
|
+
if (env.githubToken)
|
|
60
|
+
return submitViaApi(input, env, owner, repo, body, truncated);
|
|
61
|
+
return draftResult(owner, repo, input.title, body, truncated, level);
|
|
62
|
+
}
|
|
63
|
+
async function submitViaGh(input, env, owner, repo, body, truncated) {
|
|
64
|
+
const label = ISSUE_LABELS[input.contributionType];
|
|
65
|
+
const args = ["issue", "create",
|
|
66
|
+
"--repo", `${owner}/${repo}`,
|
|
67
|
+
"--title", truncateTitle(input.title),
|
|
68
|
+
"--body", body];
|
|
69
|
+
if (label)
|
|
70
|
+
args.push("--label", label);
|
|
71
|
+
const res = await execOf(env)("gh", args);
|
|
72
|
+
if (res.code === 0) {
|
|
73
|
+
const url = res.stdout.trim().split("\n").pop() ?? null;
|
|
74
|
+
return { level: 1, submitted: true, url, draft: null, deepLink: null, bodyTruncated: truncated,
|
|
75
|
+
note: "Submitted via gh CLI as your GitHub user (level 1)." };
|
|
76
|
+
}
|
|
77
|
+
return { level: 1, submitted: false, url: null, draft: body, deepLink: deepLink(owner, repo, input.title, body),
|
|
78
|
+
bodyTruncated: truncated, note: `gh CLI submission failed (exit ${res.code}): ${res.stderr.slice(0, ERROR_SNIPPET_CHARS)}` };
|
|
79
|
+
}
|
|
80
|
+
async function submitViaApi(input, env, owner, repo, body, truncated) {
|
|
81
|
+
const token = env.githubToken;
|
|
82
|
+
const doFetch = env.fetch ?? globalThis.fetch;
|
|
83
|
+
const res = await doFetch(`https://api.github.com/repos/${owner}/${repo}/issues`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Authorization": `Bearer ${token}`,
|
|
87
|
+
"Accept": "application/vnd.github+json",
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"User-Agent": "bizard-mcp",
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
title: truncateTitle(input.title),
|
|
93
|
+
body,
|
|
94
|
+
...(ISSUE_LABELS[input.contributionType] ? { labels: [ISSUE_LABELS[input.contributionType]] } : {}),
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
if (res.ok) {
|
|
98
|
+
const data = (await res.json());
|
|
99
|
+
return { level: 2, submitted: true, url: data.html_url, draft: null, deepLink: null, bodyTruncated: truncated,
|
|
100
|
+
note: "Submitted via GitHub API with your GITHUB_TOKEN (level 2)." };
|
|
101
|
+
}
|
|
102
|
+
const errText = await res.text();
|
|
103
|
+
return { level: 2, submitted: false, url: null, draft: body, deepLink: deepLink(owner, repo, input.title, body),
|
|
104
|
+
bodyTruncated: truncated, note: `GitHub API error ${res.status}: ${errText.slice(0, ERROR_SNIPPET_CHARS)} — draft retained.` };
|
|
105
|
+
}
|
|
106
|
+
/** Promisified execFile wrapper; non-zero exits resolve (code carries the status), missing binaries resolve with code 127. */
|
|
107
|
+
function execOf(env) {
|
|
108
|
+
if (env.exec)
|
|
109
|
+
return env.exec;
|
|
110
|
+
return async (cmd, args) => {
|
|
111
|
+
try {
|
|
112
|
+
const { stdout, stderr } = await execFile(cmd, args, { maxBuffer: MAX_BUFFER_BYTES });
|
|
113
|
+
return { code: 0, stdout, stderr };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
const err = e;
|
|
117
|
+
const code = typeof err.code === "number" ? err.code : 1;
|
|
118
|
+
return { code, stdout: err.stdout ?? "", stderr: err.stderr ?? err.message ?? "" };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function truncateTitle(t) {
|
|
123
|
+
return t.length > TITLE_MAX_CHARS ? t.slice(0, TITLE_MAX_CHARS - 3) + "..." : t;
|
|
124
|
+
}
|
|
125
|
+
function deepLink(owner, repo, title, body) {
|
|
126
|
+
return `https://github.com/${owner}/${repo}/issues/new?title=${encodeURIComponent(truncateTitle(title))}&body=${encodeURIComponent(body)}`;
|
|
127
|
+
}
|
|
128
|
+
function draftResult(owner, repo, title, body, truncated, level) {
|
|
129
|
+
return {
|
|
130
|
+
level, submitted: false, url: null,
|
|
131
|
+
draft: `Title: ${truncateTitle(title)}\n\n${body}`,
|
|
132
|
+
deepLink: deepLink(owner, repo, title, body),
|
|
133
|
+
bodyTruncated: truncated,
|
|
134
|
+
note: "No GitHub credentials in this environment (level 3). Open the deep-link to submit the pre-filled issue in your browser.",
|
|
135
|
+
};
|
|
136
|
+
}
|
package/dist/entry.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createBizardServer } from "./server.js";
|
|
2
|
+
import type { Bundle } from "./types.js";
|
|
3
|
+
import type { MutableHolder, NoticeQueue } from "./update-check.js";
|
|
4
|
+
export interface CliDeps {
|
|
5
|
+
loadSnapshotFn?: () => Bundle | null;
|
|
6
|
+
connect?: (server: ReturnType<typeof createBizardServer>) => Promise<void>;
|
|
7
|
+
startHttp?: (makeServer: () => ReturnType<typeof createBizardServer>, port: number) => void;
|
|
8
|
+
env?: Record<string, string | undefined>;
|
|
9
|
+
stderr?: (line: string) => void;
|
|
10
|
+
startUpdateCheck?: (holder: MutableHolder, notices: NoticeQueue) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* CLI entry logic, testable in isolation: degraded start (stderr note, server still
|
|
14
|
+
* connects) when the snapshot is missing, `--http` for the streamable-HTTP transport,
|
|
15
|
+
* and a fire-and-forget spec §3.4 update check.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runCli(argv: string[], deps?: CliDeps): Promise<void>;
|
package/dist/entry.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { loadSnapshot } from "./bundle.js";
|
|
2
|
+
import { createBizardServer } from "./server.js";
|
|
3
|
+
import { startHttpServer } from "./http.js";
|
|
4
|
+
import { checkForUpdate, createNoticeQueue } from "./update-check.js";
|
|
5
|
+
const DEFAULT_HTTP_PORT = 3000;
|
|
6
|
+
const DEGRADED_NOTE = "bizard-mcp: no bundled snapshot found. Tools will return a build hint until `npm run build:bundle` runs.";
|
|
7
|
+
/**
|
|
8
|
+
* CLI entry logic, testable in isolation: degraded start (stderr note, server still
|
|
9
|
+
* connects) when the snapshot is missing, `--http` for the streamable-HTTP transport,
|
|
10
|
+
* and a fire-and-forget spec §3.4 update check.
|
|
11
|
+
*/
|
|
12
|
+
export async function runCli(argv, deps = {}) {
|
|
13
|
+
const load = deps.loadSnapshotFn ?? loadSnapshot;
|
|
14
|
+
const log = deps.stderr ?? ((line) => console.error(line));
|
|
15
|
+
const env = deps.env ?? process.env;
|
|
16
|
+
const holder = { bundle: load(), etag: null };
|
|
17
|
+
if (!holder.bundle) {
|
|
18
|
+
log(DEGRADED_NOTE);
|
|
19
|
+
}
|
|
20
|
+
const notices = createNoticeQueue();
|
|
21
|
+
const server = createBizardServer({ get: () => holder.bundle }, notices);
|
|
22
|
+
const startCheck = deps.startUpdateCheck
|
|
23
|
+
?? ((h, ns) => {
|
|
24
|
+
void checkForUpdate(h, { notices: ns });
|
|
25
|
+
});
|
|
26
|
+
startCheck(holder, notices);
|
|
27
|
+
if (argv.includes("--http")) {
|
|
28
|
+
const port = Number(env.BIZARD_HTTP_PORT ?? DEFAULT_HTTP_PORT);
|
|
29
|
+
// The HTTP path builds a fresh server per request (one transport per
|
|
30
|
+
// McpServer — a shared instance crashes on concurrent POSTs).
|
|
31
|
+
const makeServer = () => createBizardServer({ get: () => holder.bundle }, notices);
|
|
32
|
+
if (deps.startHttp) {
|
|
33
|
+
deps.startHttp(makeServer, port);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
startHttpServer(makeServer, port);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const connect = deps.connect
|
|
41
|
+
? () => deps.connect(server)
|
|
42
|
+
: async () => {
|
|
43
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
44
|
+
await server.connect(new StdioServerTransport());
|
|
45
|
+
};
|
|
46
|
+
await connect();
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/**
|
|
3
|
+
* Stateless Streamable HTTP endpoint (spec §6.2: public anonymous access).
|
|
4
|
+
* The factory builds one McpServer per POST — the SDK allows a single
|
|
5
|
+
* transport per server instance, so sharing one across concurrent requests
|
|
6
|
+
* throws "Already connected to a transport" (and kills the process via an
|
|
7
|
+
* unhandled rejection). Per-IP token-bucket rate limiting.
|
|
8
|
+
*/
|
|
9
|
+
export declare function startHttpServer(makeServer: () => McpServer, port: number): void;
|