@bezda/rhp-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/LICENSE +21 -0
- package/README.md +222 -2
- package/bin/rhp-mcp.js +129 -0
- package/package.json +62 -4
- package/skill/SKILL.md +450 -0
- package/skill/recipes/area.html +156 -0
- package/skill/recipes/bar.html +145 -0
- package/skill/recipes/box-plot.html +164 -0
- package/skill/recipes/bubble.html +188 -0
- package/skill/recipes/bullet.html +138 -0
- package/skill/recipes/candlestick.html +179 -0
- package/skill/recipes/column.html +138 -0
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +150 -0
- package/skill/recipes/donut.html +231 -0
- package/skill/recipes/dumbbell.html +137 -0
- package/skill/recipes/gantt.html +196 -0
- package/skill/recipes/grouped-bars.html +142 -0
- package/skill/recipes/heatmap.html +183 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +170 -0
- package/skill/recipes/live.html +255 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +152 -0
- package/skill/recipes/multi-line.html +228 -0
- package/skill/recipes/pyramid.html +162 -0
- package/skill/recipes/race.html +278 -0
- package/skill/recipes/radial-bars.html +157 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +352 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +175 -0
- package/skill/recipes/stacked-100.html +158 -0
- package/skill/recipes/stacked-bars.html +150 -0
- package/skill/recipes/strip.html +221 -0
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +169 -0
- package/skill/recipes/waffle.html +165 -0
- package/skill/recipes/waterfall.html +127 -0
- package/skill/references/api.md +1684 -0
- package/skill/references/design.md +740 -0
- package/skill/references/environments.md +1577 -0
- package/skill/references/forms.md +197 -0
- package/skill/references/interaction.md +794 -0
- package/skill/references/looks.md +267 -0
- package/skill/references/pitfalls.md +202 -0
- package/src/check/README.md +176 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +508 -0
- package/src/check/inpage.js +1332 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +106 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +612 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +266 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// What each rhp component takes, and the names people write instead. The guard (rhp.js) checks props against these.
|
|
2
|
+
|
|
3
|
+
// A Plot's own settings (plot.jsx). Every other prop of a Plot is a data group.
|
|
4
|
+
export const PLOT_SETTINGS = ["children", "order", "reorder", "orientation", "overlap", "slats", "key", "rows", "animate", "thick", "class", "style", "ref", "onLoop", "static", "keyboard"];
|
|
5
|
+
|
|
6
|
+
// The settings a data group's name is checked against for typos, and what a value of each looks like
|
|
7
|
+
const isList = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && !(v instanceof DataView));
|
|
8
|
+
const isObject = (v) => v !== null && typeof v === "object" && !isList(v);
|
|
9
|
+
|
|
10
|
+
export const SETTING_VALUE = {
|
|
11
|
+
order: (v) => typeof v === "function" || (isList(v) && Array.from(v).every((x) => x == null || typeof x === "number")),
|
|
12
|
+
reorder: (v) => v === "slide" || v === "move" || v === "refill",
|
|
13
|
+
orientation: (v) => v === "horizontal" || v === "vertical" || v === "across",
|
|
14
|
+
overlap: (v) => typeof v === "boolean",
|
|
15
|
+
slats: (v) => typeof v === "number",
|
|
16
|
+
key: (v) => typeof v === "string" || typeof v === "function",
|
|
17
|
+
rows: (v) => isList(v) && v.length > 0 && Array.from(v).every(isObject),
|
|
18
|
+
animate: (v) => typeof v === "boolean" || (isList(v) && Array.from(v).every((x) => typeof x === "string")) || isObject(v),
|
|
19
|
+
thick: (v) => typeof v === "number" || typeof v === "string",
|
|
20
|
+
static: (v) => typeof v === "boolean",
|
|
21
|
+
keyboard: (v) => typeof v === "boolean",
|
|
22
|
+
class: (v) => typeof v === "string",
|
|
23
|
+
style: (v) => typeof v === "string" || isObject(v),
|
|
24
|
+
ref: (v) => typeof v === "function",
|
|
25
|
+
children: (v) => typeof v === "function",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const CHART_PROPS = ["scale", "orientation", "height", "aspect", "ticks", "grid", "format", "animate", "theme", "static", "cross", "crossTicks", "crossGrid", "crossFormat", "label", "id", "class", "role", "style", "ref", "children"];
|
|
29
|
+
|
|
30
|
+
export const THEME_KEYS = ["series", "positive", "negative", "ink", "muted", "grid", "surface", "low", "high", "font"];
|
|
31
|
+
|
|
32
|
+
export const SLAT_SETTINGS = ["css", "thickness", "inset", "room"];
|
|
33
|
+
|
|
34
|
+
export const ROOM_SIDES = ["start", "end", "before", "after"];
|
|
35
|
+
|
|
36
|
+
// Each block's own props (blocks.jsx), and its numbers
|
|
37
|
+
export const BLOCKS = {
|
|
38
|
+
Bar: { own: ["from", "to", "thick", "color", "shape"], numbers: ["from", "to"] },
|
|
39
|
+
Dot: { own: ["at", "size", "across", "cross", "color", "shape"], numbers: ["at", "across", "cross"] },
|
|
40
|
+
Tick: { own: ["at", "thick", "color", "shape"], numbers: ["at"] },
|
|
41
|
+
Cell: { own: ["value", "color", "shape"], numbers: ["value"] },
|
|
42
|
+
Place: { own: ["at", "across", "cross"], numbers: ["at", "across", "cross"] },
|
|
43
|
+
Label: { own: ["at", "side", "edge", "cross", "shape"], numbers: ["at", "cross"] },
|
|
44
|
+
Area: { own: ["points", "peak", "mirror", "smooth", "color"], numbers: ["peak"], svg: true },
|
|
45
|
+
Line: { own: ["points", "peak", "fill", "base", "smooth", "color"], numbers: ["peak", "base"], svg: true },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// What a block's length props take: a share of the band (a number) or a CSS length
|
|
49
|
+
export const LENGTHS = { Bar: ["thick"], Dot: ["size"], Tick: ["thick"] };
|
|
50
|
+
|
|
51
|
+
// Names people give a prop, and the prop it is
|
|
52
|
+
export const RENAMES = {
|
|
53
|
+
Bar: { value: "to", end: "to", length: "to", x: "to", start: "from", begin: "from", base: "from", width: "thick", height: "thick", size: "thick", thickness: "thick", fill: "color", background: "color", colour: "color" },
|
|
54
|
+
Dot: { value: "at", x: "at", y: "cross", r: "size", radius: "size", width: "size", fill: "color", background: "color", colour: "color" },
|
|
55
|
+
Tick: { value: "at", x: "at", length: "thick", width: "thick", size: "thick", fill: "color", background: "color", colour: "color" },
|
|
56
|
+
Cell: { at: "value", fill: "color", background: "color", colour: "color" },
|
|
57
|
+
Place: { value: "at", x: "at", y: "cross" },
|
|
58
|
+
Label: { value: "at", x: "at", y: "cross", position: "side", align: "side", text: "children", label: "children" },
|
|
59
|
+
Area: { data: "points", values: "points", max: "peak", fill: "color", colour: "color", curved: "smooth" },
|
|
60
|
+
Line: { data: "points", values: "points", max: "peak", curved: "smooth", area: "fill", colour: "color" },
|
|
61
|
+
Chart: { domain: "scale", range: "scale", extent: "scale", horizontal: "orientation", vertical: "orientation", direction: "orientation", colors: "theme", palette: "theme", tickFormat: "format", ariaLabel: "label", title: "label" },
|
|
62
|
+
Poster: { headline: "title", heading: "title", subtitle: "dek", subheading: "dek", subhead: "dek", description: "dek", lede: "dek", standfirst: "dek", source: "note", footnote: "note", credit: "note", credits: "note", caption: "note", footer: "note", eyebrow: "kicker", overline: "kicker", tag: "kicker" },
|
|
63
|
+
slat: { height: "thickness", width: "thickness", size: "thickness", thick: "thickness", pitch: "thickness", padding: "room", margin: "room", gutter: "room", gutters: "room", gap: "inset", style: "css", styles: "css", class: "css", className: "css" },
|
|
64
|
+
theme: { colors: "series", palette: "series", accent: "series", text: "ink", foreground: "ink", color: "ink", background: "surface", bg: "surface", paper: "surface", gridline: "grid", gridlines: "grid", axis: "grid", fontFamily: "font", family: "font", typeface: "font" },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// What goes on a block's element as it is: the global HTML attributes (as Solid takes them), and SVG's for Area and Line
|
|
68
|
+
const GLOBAL = ["accesskey", "accessKey", "autocapitalize", "autoCapitalize", "autocorrect", "autofocus", "autoFocus", "class", "classList", "contenteditable", "contentEditable", "dir", "draggable", "enterkeyhint", "enterKeyHint", "exportparts", "hidden", "id", "inert", "inputmode", "inputMode", "is", "itemid", "itemprop", "itemProp", "itemref", "itemscope", "itemScope", "itemtype", "itemType", "lang", "nonce", "part", "popover", "role", "slot", "spellcheck", "spellCheck", "style", "tabindex", "tabIndex", "title", "translate", "writingsuggestions", "ref", "children", "innerHTML", "textContent", "innerText"];
|
|
69
|
+
const SVG = ["opacity", "transform", "filter", "mask", "stroke", "fill", "x", "y", "width", "height", "overflow", "visibility", "display", "cursor", "pathLength", "preserveAspectRatio", "viewBox", "xmlns", "focusable"];
|
|
70
|
+
|
|
71
|
+
export const HTML_ATTRIBUTES = new Set(GLOBAL);
|
|
72
|
+
export const SVG_ATTRIBUTES = new Set([...GLOBAL, ...SVG]);
|
|
73
|
+
|
|
74
|
+
// data-*, aria-*, handlers (onClick, on:click, oncapture:click), Solid's namespaces (use:, prop:, attr:, bool:), and
|
|
75
|
+
// SVG's hyphenated attributes (stroke-width)
|
|
76
|
+
export const passesThrough = (name) => /^(data|aria)-/.test(name) || /^on[A-Za-z:]/.test(name) || /^(use|prop|attr|bool):/.test(name) || /^[a-z]+(-[a-z]+)+$/.test(name);
|
|
77
|
+
|
|
78
|
+
// Edit distance (a swap of two letters side by side counts as one edit), with a cap: anything past it counts as far
|
|
79
|
+
function distance(a, b, cap = 3) {
|
|
80
|
+
|
|
81
|
+
if (Math.abs(a.length - b.length) > cap) return cap + 1;
|
|
82
|
+
|
|
83
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
84
|
+
|
|
85
|
+
for (let j = 1; j <= b.length; j++) {
|
|
86
|
+
d[0][j] = j;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (let i = 1; i <= a.length; i++) {
|
|
90
|
+
for (let j = 1; j <= b.length; j++) {
|
|
91
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
92
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
93
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return d[a.length][b.length];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The name in a list closest to a name, if one is close enough to be a typo of it: one edit for names of up to six
|
|
101
|
+
// letters, two for longer ones
|
|
102
|
+
export function closest(name, names) {
|
|
103
|
+
|
|
104
|
+
const low = name.toLowerCase();
|
|
105
|
+
let best = null;
|
|
106
|
+
let bestD = Infinity;
|
|
107
|
+
|
|
108
|
+
for (const candidate of names) {
|
|
109
|
+
const cap = Math.min(name.length, candidate.length) <= 6 ? 1 : 2;
|
|
110
|
+
const d = distance(low, candidate.toLowerCase(), cap);
|
|
111
|
+
if (d <= cap && d < bestD) {
|
|
112
|
+
best = candidate;
|
|
113
|
+
bestD = d;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return best;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const THEME_KEY = /^(series-\d+|positive|negative|ink|muted|grid|surface|low|high)$/;
|
|
121
|
+
|
|
122
|
+
// A data group that is a palette: every item a color or a theme key
|
|
123
|
+
export const isPalette = (list, isColor) => list.length > 0 && Array.from(list).every((c) => typeof c === "string" && (THEME_KEY.test(c) || isColor(c)));
|
|
124
|
+
|
|
125
|
+
export { isList, isObject };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The MCP server, over stdio: the rhp skill's guide, references and recipes, and the checker, as four tools and one
|
|
2
|
+
// prompt. stdout carries the protocol, so everything else goes to stderr.
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { SKILL_DIR, skillBody, references, recipes, reference, recipe } from "./skill.js";
|
|
10
|
+
|
|
11
|
+
console.log = console.info = console.debug = console.error;
|
|
12
|
+
|
|
13
|
+
const { version } = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
14
|
+
|
|
15
|
+
// A check that runs longer than this (in seconds) is stuck, a page in an endless loop: it is stopped and reported.
|
|
16
|
+
// Checks take 3 to 11 seconds; many clients stop waiting for a tool at 60.
|
|
17
|
+
const LIMIT = 50;
|
|
18
|
+
|
|
19
|
+
// The screenshots go back at most 1280px wide and 2000px tall: Claude refuses a request holding over 20 images when
|
|
20
|
+
// one is larger than 2000px, and a few checks reach 20 images
|
|
21
|
+
const MAX_WIDTH = 1280;
|
|
22
|
+
const MAX_HEIGHT = 2000;
|
|
23
|
+
|
|
24
|
+
const INSTRUCTIONS = `rhp 2 (@bezda/rhp) makes charts out of HTML elements and CSS with SolidJS. It came out in September 2026, after your training data, so what you remember about rhp is wrong or belongs to the unrelated rhp 1. Before you write or change any rhp code, call rhp_guide and follow its workflow. Take each chart's technique from the closest recipe (rhp_recipe) and design the chart for its own subject, and look up every prop you are unsure of with rhp_reference instead of guessing. After every edit, call rhp_check with the chart file's absolute path, fix what it reports and look at its screenshots, until it reports no errors and no warnings.`;
|
|
25
|
+
|
|
26
|
+
const text = (body) => ({ content: [{ type: "text", text: body }] });
|
|
27
|
+
const failure = (body) => ({ content: [{ type: "text", text: body }], isError: true });
|
|
28
|
+
|
|
29
|
+
// The guide: SKILL.md with a note on reading it through this server, and the names the other tools take
|
|
30
|
+
function guide() {
|
|
31
|
+
|
|
32
|
+
return [
|
|
33
|
+
"You are reading the rhp skill through the rhp MCP server, so:",
|
|
34
|
+
"- where it says to run the checker, call the rhp_check tool with the chart file's absolute path;",
|
|
35
|
+
'- where it links a reference such as references/api.md, call rhp_reference with its name ("api");',
|
|
36
|
+
'- where it names a recipe such as recipes/bar.html or `bar`, call rhp_recipe with its type ("bar").',
|
|
37
|
+
"",
|
|
38
|
+
skillBody().trimEnd(),
|
|
39
|
+
"",
|
|
40
|
+
"## The references and recipes this server returns",
|
|
41
|
+
"",
|
|
42
|
+
"rhp_reference names:",
|
|
43
|
+
...references().map((r) => `- ${r.name}: ${r.about}`),
|
|
44
|
+
"",
|
|
45
|
+
`rhp_recipe types: ${recipes().map((r) => r.type).join(", ")}. Call rhp_recipe with no type to see what each is for.`,
|
|
46
|
+
"",
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function recipeList() {
|
|
51
|
+
|
|
52
|
+
const all = recipes();
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
`${all.length} recipes. Each is a complete, tested chart page: call rhp_recipe with its type, for example { "type": "bar" }, to get the whole file.`,
|
|
56
|
+
"",
|
|
57
|
+
...all.map((r) => `- ${r.type}: "${r.title}". ${r.description}`),
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// One browser for every check: launched by the first, launched again if it died, closed when the server stops.
|
|
63
|
+
// With no browser, { error } (the check then reports how to install one).
|
|
64
|
+
let launched = null;
|
|
65
|
+
|
|
66
|
+
async function sharedBrowser() {
|
|
67
|
+
|
|
68
|
+
const current = (launched ??= import("./check/browser.js").then(({ launch }) => launch()).catch((e) => ({ error: [String(e?.message ?? e)] })));
|
|
69
|
+
const got = await current;
|
|
70
|
+
if (got.browser?.isConnected()) return got;
|
|
71
|
+
if (launched === current) launched = null;
|
|
72
|
+
|
|
73
|
+
return got.browser ? sharedBrowser() : got;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Closes a browser (the shared one when none is given), and forgets it when it is the shared one
|
|
77
|
+
async function closeBrowser(browser) {
|
|
78
|
+
|
|
79
|
+
const shared = (await launched)?.browser;
|
|
80
|
+
if (!browser || browser === shared) launched = null;
|
|
81
|
+
await (browser ?? shared)?.close().catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The screenshots as JPEG images, drawn again on a canvas in the browser
|
|
85
|
+
async function images(browser, shots) {
|
|
86
|
+
|
|
87
|
+
const page = await browser.newPage();
|
|
88
|
+
const out = [];
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
for (const shot of shots) {
|
|
92
|
+
const data = await page.evaluate(async ({ png, width, height }) => {
|
|
93
|
+
const img = new Image();
|
|
94
|
+
img.src = "data:image/png;base64," + png;
|
|
95
|
+
await img.decode();
|
|
96
|
+
const k = Math.min(1, width / img.naturalWidth, height / img.naturalHeight);
|
|
97
|
+
const canvas = document.createElement("canvas");
|
|
98
|
+
canvas.width = Math.round(img.naturalWidth * k);
|
|
99
|
+
canvas.height = Math.round(img.naturalHeight * k);
|
|
100
|
+
const g = canvas.getContext("2d");
|
|
101
|
+
g.imageSmoothingQuality = "high";
|
|
102
|
+
g.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
103
|
+
return canvas.toDataURL("image/jpeg", 0.8).split(",")[1];
|
|
104
|
+
}, { png: fs.readFileSync(shot.path).toString("base64"), width: MAX_WIDTH, height: MAX_HEIGHT });
|
|
105
|
+
out.push({ shot, data });
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
await page.close().catch(() => {});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Checks a chart: the report, then each screenshot as an image after a line saying what it shows
|
|
115
|
+
async function checkTool({ file, code, format, widths, dark, interact, settleTimeout }) {
|
|
116
|
+
|
|
117
|
+
if (file && code) return failure("Give file or code, not both.");
|
|
118
|
+
if (!file && !code) return failure("Give the chart to check: file (the absolute path of its file) or code (its code).");
|
|
119
|
+
|
|
120
|
+
if (file) {
|
|
121
|
+
if (file.startsWith("file:")) {
|
|
122
|
+
try {
|
|
123
|
+
file = fileURLToPath(file);
|
|
124
|
+
} catch {
|
|
125
|
+
return failure(`${file} is not a file URL this server can read. Give the chart file's absolute path.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!path.isAbsolute(file)) return failure(`"${file}" is a relative path. Give the chart file's absolute path (for example /home/me/project/chart.html): this server runs in its own folder, not in your project, so it cannot tell which file a relative path means.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { check } = await import("./check/index.js");
|
|
132
|
+
const got = await sharedBrowser();
|
|
133
|
+
const browser = got.browser;
|
|
134
|
+
const running = check({ file, code, format, widths, dark, interact, settleTimeout, browser }).then((result) => ({ result }), (error) => ({ error }));
|
|
135
|
+
let timer;
|
|
136
|
+
const late = new Promise((resolve) => {
|
|
137
|
+
timer = setTimeout(resolve, LIMIT * 1000, { late: true });
|
|
138
|
+
});
|
|
139
|
+
const done = await Promise.race([running, late]);
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
|
|
142
|
+
if (done.late) {
|
|
143
|
+
if (browser) await closeBrowser(browser);
|
|
144
|
+
return failure(`The check stopped after ${LIMIT} seconds: the chart's page never finished. A page stuck in an endless loop does this (a loop whose end never comes, or an effect that keeps setting a signal it reads). Find it, fix it and check again.`);
|
|
145
|
+
}
|
|
146
|
+
if ("error" in done) {
|
|
147
|
+
console.error(`rhp-mcp: the check failed: ${done.error?.stack ?? done.error}`);
|
|
148
|
+
if (browser) await closeBrowser(browser);
|
|
149
|
+
return failure(`The check failed before it could report: ${done.error?.message ?? done.error}. Call rhp_check again; it starts a new browser.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = done.result;
|
|
153
|
+
const content = [{ type: "text", text: result.text }];
|
|
154
|
+
const shots = [...result.screenshots].sort((a, b) => b.width - a.width || a.hover - b.hover);
|
|
155
|
+
|
|
156
|
+
if (browser && shots.length) {
|
|
157
|
+
try {
|
|
158
|
+
const first = new Map();
|
|
159
|
+
for (const { shot, data } of await images(browser, shots)) {
|
|
160
|
+
const label = `Screenshot at ${shot.width}px${shot.dark ? ", dark" : ""}${shot.hover ? ", pointing at a slat" : ""}`;
|
|
161
|
+
if (shot.hover && first.get(shot.width) === data) {
|
|
162
|
+
content.push({ type: "text", text: `${label}: the same as the one above (pointing at a slat changed nothing visible).` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!shot.hover) first.set(shot.width, data);
|
|
166
|
+
content.push({ type: "text", text: `${label}:` });
|
|
167
|
+
content.push({ type: "image", data, mimeType: "image/jpeg" });
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.error(`rhp-mcp: the screenshots could not be attached: ${e.message}`);
|
|
171
|
+
content.push({ type: "text", text: "The screenshots could not be attached here: open them at the paths above and look at them." });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { content };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const server = new McpServer({ name: "rhp", title: "rhp charts", version }, { instructions: INSTRUCTIONS });
|
|
179
|
+
|
|
180
|
+
server.registerTool("rhp_guide", {
|
|
181
|
+
title: "How to make charts with rhp",
|
|
182
|
+
description: "Start here, before you write or change any rhp chart code. Returns how to make a chart with rhp 2 (@bezda/rhp): the workflow from the request to a checked chart, the rules that prevent most bugs, the code format, and the names of the references and recipes the other tools return. rhp 2 is newer than your training data, so call this first, once per conversation in which you make or change an rhp chart, and follow it.",
|
|
183
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
184
|
+
}, async () => text(guide()));
|
|
185
|
+
|
|
186
|
+
server.registerTool("rhp_reference", {
|
|
187
|
+
title: "rhp reference",
|
|
188
|
+
description: "Returns one of rhp's references by name: api (every component, prop, helper and CSS variable, exactly), design (art direction from the subject, the editorial poster, color, type, layout), looks (eight looks written out in full, as examples), interaction (which interaction for which story, with code), environments (plain HTML, Solid, React, Next.js, Vue, Svelte, Angular, Astro), forms (the subject's own form, which chart for which data and story), pitfalls (mistakes and their fixes). Look props up here instead of guessing. A long reference comes back as its list of sections: call again with section to get the one you need.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
name: z.string().describe("The reference: api, design, environments, forms, interaction, looks or pitfalls"),
|
|
191
|
+
section: z.string().optional().describe('One "##" section of the reference, by its number or title, for example "4" or "Plot"'),
|
|
192
|
+
},
|
|
193
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
194
|
+
}, async ({ name, section }) => {
|
|
195
|
+
|
|
196
|
+
const got = reference(name, section);
|
|
197
|
+
|
|
198
|
+
return got.error ? failure(got.error) : text(got.text);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
server.registerTool("rhp_recipe", {
|
|
202
|
+
title: "rhp recipe",
|
|
203
|
+
description: 'Lists rhp\'s recipes (call it with no type), or returns one recipe\'s complete HTML file (type, for example "bar"). A recipe is a tested chart page that shows the technique for one kind of chart: take its technique (how its Plots, slats and blocks are composed, its interaction, its phone rules) and leave its design, which was made for its own subject.',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
type: z.string().optional().describe('The recipe, for example "bar", "line" or "donut"; leave it out for the list'),
|
|
206
|
+
},
|
|
207
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
208
|
+
}, async ({ type }) => {
|
|
209
|
+
|
|
210
|
+
if (type === undefined || !type.trim()) return text(recipeList());
|
|
211
|
+
const got = recipe(type);
|
|
212
|
+
|
|
213
|
+
return got.error ? failure(got.error) : text(got.text);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
server.registerTool("rhp_check", {
|
|
217
|
+
title: "Check an rhp chart",
|
|
218
|
+
description: "Renders an rhp chart in a headless browser at 1280px and 390px wide, tries its interactions, and reports what is wrong: errors and warnings, each with what to change, then screenshots to look at. Call it after every edit to a chart until it reports no errors and no warnings, and look at the screenshots each time. Give file, the chart file's ABSOLUTE path (an .html page, a Solid or React .jsx/.tsx component, or a .js module that uses @bezda/rhp/standalone), or code when you cannot write files.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
file: z.string().optional().describe("The chart file's absolute path"),
|
|
221
|
+
code: z.string().optional().describe("The chart's code, instead of a file, when you cannot write files"),
|
|
222
|
+
format: z.enum(["html", "solid", "react", "module"]).optional().describe("What the code is; found from the file's extension and the code when left out"),
|
|
223
|
+
widths: z.array(z.number().int().min(200).max(2560)).min(1).max(6).optional().describe("Page widths in px, [1280, 390] by default; the interactions are tried at the first, and slats are tapped at phone widths (600px or less)"),
|
|
224
|
+
dark: z.boolean().optional().describe("Render with a dark color scheme (prefers-color-scheme: dark)"),
|
|
225
|
+
interact: z.boolean().optional().describe("Try the chart's interactions (true by default)"),
|
|
226
|
+
settleTimeout: z.number().int().min(100).max(30000).optional().describe("Maximum milliseconds per readiness wait, 10000 by default. Increase for a slow machine; the overall check still has a 50-second limit."),
|
|
227
|
+
},
|
|
228
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
229
|
+
}, checkTool);
|
|
230
|
+
|
|
231
|
+
server.registerPrompt("chart", {
|
|
232
|
+
title: "Make a chart with rhp",
|
|
233
|
+
description: "Make a chart with rhp, following rhp_guide, checked with rhp_check until it is clean",
|
|
234
|
+
argsSchema: {
|
|
235
|
+
request: z.string().describe("The chart: its subject and data, and anything about its look, interaction or framework"),
|
|
236
|
+
},
|
|
237
|
+
}, ({ request }) => ({
|
|
238
|
+
messages: [{
|
|
239
|
+
role: "user",
|
|
240
|
+
content: {
|
|
241
|
+
type: "text",
|
|
242
|
+
text: `Make this chart with rhp (@bezda/rhp): ${request}\n\nFirst call rhp_guide and follow its workflow: write the brief, take the technique from the closest recipe (rhp_recipe), design the chart for its own subject, and look up props with rhp_reference instead of guessing. After every edit, call rhp_check with the chart file's absolute path, fix what it reports and look at its screenshots, until it reports no errors and no warnings.`,
|
|
243
|
+
},
|
|
244
|
+
}],
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
// Stop when the client goes: close the browser first, so no Chromium is left running
|
|
248
|
+
let stopping = false;
|
|
249
|
+
|
|
250
|
+
async function stop() {
|
|
251
|
+
|
|
252
|
+
if (stopping) return;
|
|
253
|
+
stopping = true;
|
|
254
|
+
await closeBrowser();
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
process.stdin.on("end", stop);
|
|
259
|
+
process.stdin.on("close", stop);
|
|
260
|
+
process.on("SIGINT", stop);
|
|
261
|
+
process.on("SIGTERM", stop);
|
|
262
|
+
process.on("unhandledRejection", (e) => console.error(`rhp-mcp: ${e?.stack ?? e}`));
|
|
263
|
+
server.server.onclose = stop;
|
|
264
|
+
|
|
265
|
+
await server.connect(new StdioServerTransport());
|
|
266
|
+
console.error(`rhp-mcp ${version}: ready, serving the skill in ${SKILL_DIR}`);
|
package/src/skill.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The rhp skill's text, as the MCP server and `rhp-mcp guide` serve it: SKILL.md, its references and its recipes.
|
|
2
|
+
// In rhp's repository it is read from skills/rhp itself, so it is never stale; in the published package, from the copy
|
|
3
|
+
// `npm run build` makes in skill/.
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
|
|
8
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const REPO = path.join(here, "../../skills/rhp");
|
|
10
|
+
const COPY = path.join(here, "../skill");
|
|
11
|
+
|
|
12
|
+
export const SKILL_DIR = fs.existsSync(path.join(REPO, "SKILL.md")) ? REPO : COPY;
|
|
13
|
+
|
|
14
|
+
// A reference longer than this comes back as its list of sections (it would cost the agent over 10,000 tokens)
|
|
15
|
+
const WHOLE = 40 * 1024;
|
|
16
|
+
|
|
17
|
+
const read = (file) => fs.readFileSync(path.join(SKILL_DIR, file), "utf8");
|
|
18
|
+
const kb = (text) => `${Math.max(1, Math.round(Buffer.byteLength(text) / 1024))} KB`;
|
|
19
|
+
const entities = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" };
|
|
20
|
+
const decode = (text) => (text ?? "").replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => entities[e]);
|
|
21
|
+
|
|
22
|
+
// SKILL.md without its frontmatter
|
|
23
|
+
export function skillBody() {
|
|
24
|
+
|
|
25
|
+
return read("SKILL.md").replace(/^---\n[\s\S]*?\n---\n+/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The references: [{ name, about, file }], about from SKILL.md's list of references, else the file's title
|
|
29
|
+
export function references() {
|
|
30
|
+
|
|
31
|
+
const dir = path.join(SKILL_DIR, "references");
|
|
32
|
+
const listed = new Map([...read("SKILL.md").matchAll(/^- \[[^\]]*\]\(references\/([\w-]+)\.md\):\s*(.+)$/gm)].map((m) => [m[1], m[2]]));
|
|
33
|
+
|
|
34
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort().map((f) => {
|
|
35
|
+
const name = f.slice(0, -3);
|
|
36
|
+
const about = listed.get(name) ?? fs.readFileSync(path.join(dir, f), "utf8").match(/^# (.+)$/m)?.[1] ?? "";
|
|
37
|
+
return { name, about, file: path.join(dir, f) };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The recipes: [{ type, title, description, file }]
|
|
42
|
+
export function recipes() {
|
|
43
|
+
|
|
44
|
+
const dir = path.join(SKILL_DIR, "recipes");
|
|
45
|
+
|
|
46
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".html")).sort().map((f) => {
|
|
47
|
+
const text = fs.readFileSync(path.join(dir, f), "utf8");
|
|
48
|
+
return {
|
|
49
|
+
type: f.slice(0, -5),
|
|
50
|
+
title: decode(text.match(/<title>([^<]*)<\/title>/)?.[1]),
|
|
51
|
+
description: decode(text.match(/<meta name="description" content="([^"]*)"/)?.[1]),
|
|
52
|
+
file: path.join(dir, f),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A markdown file's "## " sections, each with its first line of prose (headings inside code blocks don't count)
|
|
58
|
+
function sections(text) {
|
|
59
|
+
|
|
60
|
+
const lines = text.split("\n");
|
|
61
|
+
const found = [];
|
|
62
|
+
let fenced = false;
|
|
63
|
+
|
|
64
|
+
lines.forEach((line, i) => {
|
|
65
|
+
if (/^\s*```/.test(line)) fenced = !fenced;
|
|
66
|
+
if (!fenced && line.startsWith("## ")) found.push({ title: line.slice(3).trim(), start: i });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return found.map((s, k) => {
|
|
70
|
+
const body = lines.slice(s.start, found[k + 1]?.start ?? lines.length);
|
|
71
|
+
let inCode = false;
|
|
72
|
+
const first = body.slice(1).find((line) => {
|
|
73
|
+
if (/^\s*```/.test(line)) inCode = !inCode;
|
|
74
|
+
return !inCode && line.trim() && !/^\s*(```|#|\||<!--)/.test(line);
|
|
75
|
+
});
|
|
76
|
+
return { title: s.title, first: first?.trim() ?? "", text: body.join("\n").trimEnd() + "\n" };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The section a query names: its number ("4"), its title ("Plot"), the start of its title, or words in it
|
|
81
|
+
function findSection(list, query) {
|
|
82
|
+
|
|
83
|
+
const q = query.trim().toLowerCase().replace(/^§\s*/, "");
|
|
84
|
+
const number = /^\d+\.?$/.test(q) ? parseInt(q, 10) : null;
|
|
85
|
+
const title = (s) => s.title.toLowerCase().replace(/^\d+\.\s*/, "");
|
|
86
|
+
|
|
87
|
+
return list.find((s) => number !== null && parseInt(s.title, 10) === number)
|
|
88
|
+
?? list.find((s) => title(s) === q)
|
|
89
|
+
?? list.find((s) => title(s).startsWith(q))
|
|
90
|
+
?? list.find((s) => s.title.toLowerCase().includes(q));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One reference: { text } (the whole file, its list of sections when it is long, or the section asked for) or { error }
|
|
94
|
+
export function reference(name, section) {
|
|
95
|
+
|
|
96
|
+
const all = references();
|
|
97
|
+
const key = String(name ?? "").trim().toLowerCase().replace(/^references\//, "").replace(/\.md$/, "");
|
|
98
|
+
const ref = all.find((r) => r.name === key);
|
|
99
|
+
if (!ref) return { error: `There is no reference "${name}". The references are:\n${all.map((r) => `- ${r.name}: ${r.about}`).join("\n")}` };
|
|
100
|
+
|
|
101
|
+
const text = fs.readFileSync(ref.file, "utf8");
|
|
102
|
+
const list = sections(text).filter((s) => !/^contents$/i.test(s.title));
|
|
103
|
+
|
|
104
|
+
if (section !== undefined && section !== null && String(section).trim()) {
|
|
105
|
+
const found = findSection(list, String(section));
|
|
106
|
+
if (found) return { text: found.text };
|
|
107
|
+
return { error: `references/${ref.name}.md has no section "${section}". Its sections are:\n${list.map((s) => `- ${s.title}`).join("\n")}` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (Buffer.byteLength(text) <= WHOLE || !list.length) return { text };
|
|
111
|
+
|
|
112
|
+
const intro = text.slice(0, text.indexOf("\n## ") + 1).split("\n")
|
|
113
|
+
.filter((line) => !/^Contents\b/.test(line) && !/^\s*(?:[-*]|\d+\.)\s*\[[^\]]*\]\(#/.test(line))
|
|
114
|
+
.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
115
|
+
const pick = list[Math.min(3, list.length - 1)].title;
|
|
116
|
+
const examples = [parseInt(pick, 10), pick.replace(/^\d+\.\s*/, "")].filter((x) => !Number.isNaN(x));
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
text: [
|
|
120
|
+
intro,
|
|
121
|
+
"",
|
|
122
|
+
`This reference is ${kb(text)}, too long to return whole, so here are its sections.`,
|
|
123
|
+
`Ask for the ones you need: call rhp_reference again with name "${ref.name}" and section set to a section's number or title, for example ${examples.map((x) => `{ "name": "${ref.name}", "section": "${x}" }`).join(" or ")}.`,
|
|
124
|
+
"",
|
|
125
|
+
...list.map((s) => `- ${s.title} (${kb(s.text)})${s.first ? `: ${s.first}` : ""}`),
|
|
126
|
+
"",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One recipe's whole file: { text } or { error }
|
|
132
|
+
export function recipe(type) {
|
|
133
|
+
|
|
134
|
+
const all = recipes();
|
|
135
|
+
const key = String(type ?? "").trim().toLowerCase().replace(/^recipes\//, "").replace(/\.html$/, "").replace(/\s+/g, "-");
|
|
136
|
+
const found = all.find((r) => r.type === key);
|
|
137
|
+
if (!found) return { error: `There is no recipe "${type}". The recipes are: ${all.map((r) => r.type).join(", ")}. Call rhp_recipe with no type to see what each is for.` };
|
|
138
|
+
|
|
139
|
+
return { text: fs.readFileSync(found.file, "utf8") };
|
|
140
|
+
}
|