@twentyfirstmedia/ui 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 +30 -0
- package/README.md +43 -2
- package/dist/add.d.ts +10 -0
- package/dist/add.d.ts.map +1 -0
- package/dist/add.js +28 -0
- package/dist/add.js.map +1 -0
- package/dist/diff.d.ts +6 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +57 -0
- package/dist/diff.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +160 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +12 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +79 -0
- package/dist/init.js.map +1 -0
- package/dist/knowledge.d.ts +124 -0
- package/dist/knowledge.d.ts.map +1 -0
- package/dist/knowledge.js +494 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/knowledge.json +1 -0
- package/dist/list.d.ts +3 -0
- package/dist/list.d.ts.map +1 -0
- package/dist/list.js +21 -0
- package/dist/list.js.map +1 -0
- package/dist/mcp-init.d.ts +21 -0
- package/dist/mcp-init.d.ts.map +1 -0
- package/dist/mcp-init.js +114 -0
- package/dist/mcp-init.js.map +1 -0
- package/dist/mcp.d.ts +72 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +768 -0
- package/dist/mcp.js.map +1 -0
- package/dist/project.d.ts +14 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +31 -0
- package/dist/project.js.map +1 -0
- package/dist/shared/theme.d.ts +61 -0
- package/dist/shared/theme.d.ts.map +1 -0
- package/dist/shared/theme.js +200 -0
- package/dist/shared/theme.js.map +1 -0
- package/dist/source.d.ts +35 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +229 -0
- package/dist/source.js.map +1 -0
- package/dist/theme.d.ts +18 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +65 -0
- package/dist/theme.js.map +1 -0
- package/dist/util.d.ts +21 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +29 -0
- package/dist/util.js.map +1 -0
- package/package.json +47 -3
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server (Model Context Protocol): `twentyfirst-ui mcp --kit halo`.
|
|
3
|
+
*
|
|
4
|
+
* Gives coding agents the kit docs of the installed version: tools, resources and prompts over
|
|
5
|
+
* stdio (newline-delimited JSON-RPC 2.0). No dependencies and no network access: the docs come
|
|
6
|
+
* from knowledge.json, which the build writes next to this file. Logs go to stderr only, stdout
|
|
7
|
+
* belongs to the protocol.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { agentRules, allEntries, appCode, entryMarkdown, exportedNames, findEntry, findGuide, forKit, guideMarkdown, checkUsage, componentsIndexMarkdown, kitInfo, llmsFull, pageUrl, plain, propsMarkdown, search, tokensMarkdown, PRODUCT, TOKEN_GROUPS, } from "./knowledge.js";
|
|
11
|
+
export const SERVER_NAME = "twentyfirst-ui";
|
|
12
|
+
export const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
13
|
+
export const LATEST_PROTOCOL_VERSION = PROTOCOL_VERSIONS[0];
|
|
14
|
+
const SCHEME = `${SERVER_NAME}://`;
|
|
15
|
+
const MARKDOWN = "text/markdown";
|
|
16
|
+
export const ErrorCode = {
|
|
17
|
+
ParseError: -32700,
|
|
18
|
+
InvalidRequest: -32600,
|
|
19
|
+
MethodNotFound: -32601,
|
|
20
|
+
InvalidParams: -32602,
|
|
21
|
+
InternalError: -32603,
|
|
22
|
+
ResourceNotFound: -32002,
|
|
23
|
+
};
|
|
24
|
+
class RpcError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
data;
|
|
27
|
+
constructor(code, message, data) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.data = data;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Thrown inside a tool: becomes a result with isError, so the agent can correct itself. */
|
|
34
|
+
class ToolError extends Error {
|
|
35
|
+
}
|
|
36
|
+
// ── helpers ──
|
|
37
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
38
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
39
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
40
|
+
const json = (v) => JSON.stringify(v);
|
|
41
|
+
function stringArg(args, key, required = false) {
|
|
42
|
+
const v = args[key];
|
|
43
|
+
if (v === undefined || v === null || v === "") {
|
|
44
|
+
if (required)
|
|
45
|
+
throw new ToolError(`Missing the required argument "${key}".`);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (typeof v !== "string")
|
|
49
|
+
throw new ToolError(`The argument "${key}" must be a string.`);
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
function kitArg(args, fallback) {
|
|
53
|
+
const v = stringArg(args, "kit");
|
|
54
|
+
if (v === undefined)
|
|
55
|
+
return fallback;
|
|
56
|
+
const k = v.toLowerCase();
|
|
57
|
+
if (k !== "halo" && k !== "pro")
|
|
58
|
+
throw new ToolError(`"kit" must be "halo" or "pro", not "${v}".`);
|
|
59
|
+
return k;
|
|
60
|
+
}
|
|
61
|
+
/** `import { A, B } from "@twentyfirstmedia/<kit>";` for the components a page documents. */
|
|
62
|
+
export function importLine(k, e, kit) {
|
|
63
|
+
const exported = exportedNames(k);
|
|
64
|
+
const names = e.components.filter((c) => exported.has(c));
|
|
65
|
+
return names.length ? `import { ${names.join(", ")} } from "${kitInfo(k, kit).package}";` : "";
|
|
66
|
+
}
|
|
67
|
+
/** Entry Markdown with an Import section before the example. */
|
|
68
|
+
function entryWithImport(k, e, kit) {
|
|
69
|
+
const md = entryMarkdown(k, e, kit);
|
|
70
|
+
const line = importLine(k, e, kit);
|
|
71
|
+
if (!line)
|
|
72
|
+
return md;
|
|
73
|
+
return md.replace(/\n## (Example|Code)\n/, (_m, h) => `\n## Import\n\n\`\`\`tsx\n${line}\n\`\`\`\n\n## ${h}\n`);
|
|
74
|
+
}
|
|
75
|
+
/** Edit distance, for "did you mean" on typos. */
|
|
76
|
+
function distance(a, b) {
|
|
77
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
78
|
+
for (let i = 1; i <= a.length; i++) {
|
|
79
|
+
const cur = [i];
|
|
80
|
+
for (let j = 1; j <= b.length; j++)
|
|
81
|
+
cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
82
|
+
prev = cur;
|
|
83
|
+
}
|
|
84
|
+
return prev[b.length] ?? 0;
|
|
85
|
+
}
|
|
86
|
+
/** Close names for an unknown one: typos first, then search hits. */
|
|
87
|
+
function closest(k, query, kinds, limit = 5) {
|
|
88
|
+
const n = norm(query);
|
|
89
|
+
const pool = allEntries(k).filter((e) => kinds.includes(e.path.startsWith("patterns/") ? "pattern" : "component"));
|
|
90
|
+
const names = new Map();
|
|
91
|
+
for (const e of pool)
|
|
92
|
+
names.set(e.name, e.summary);
|
|
93
|
+
if (kinds.includes("component"))
|
|
94
|
+
for (const x of exportedNames(k))
|
|
95
|
+
if (/^[A-Z]/.test(x) && !names.has(x))
|
|
96
|
+
names.set(x, "documented on the pages that use it");
|
|
97
|
+
const typos = [...names.keys()]
|
|
98
|
+
.map((name) => ({ name, d: distance(n, norm(name)) }))
|
|
99
|
+
.filter((x) => x.d <= Math.max(1, Math.floor(n.length / 4)))
|
|
100
|
+
.sort((a, b) => a.d - b.d)
|
|
101
|
+
.map((x) => x.name);
|
|
102
|
+
const hits = search(k, query, 50)
|
|
103
|
+
.filter((h) => kinds.includes(h.kind))
|
|
104
|
+
.map((h) => h.name);
|
|
105
|
+
return [...new Set([...typos, ...hits])]
|
|
106
|
+
.slice(0, limit)
|
|
107
|
+
.map((name) => `- ${name}: ${names.get(name) ?? ""}`)
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
function tokenGroupOf(k, name) {
|
|
111
|
+
const bare = name.replace(/^--/, "");
|
|
112
|
+
const tokens = kitInfo(k, "halo").tokens;
|
|
113
|
+
return Object.keys(tokens).find((g) => tokens[g]?.some((t) => t.name === bare));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Docs of a component by name. A page of its own wins; a component without one (Button, Badge…)
|
|
117
|
+
* gets its import, props and the example of the page that shows it best; else any page that
|
|
118
|
+
* documents it (findEntry).
|
|
119
|
+
*/
|
|
120
|
+
export function componentDoc(k, name, kit) {
|
|
121
|
+
const n = norm(name);
|
|
122
|
+
const all = allEntries(k);
|
|
123
|
+
const own = all.find((e) => norm(e.name) === n || norm(e.title) === n) ??
|
|
124
|
+
k.patterns.find((e) => norm(e.name) === `${n}screen`);
|
|
125
|
+
if (own)
|
|
126
|
+
return entryWithImport(k, own, kit);
|
|
127
|
+
const exported = [...exportedNames(k)].find((x) => norm(x) === n && /^[A-Z]/.test(x));
|
|
128
|
+
const pages = exported ? all.filter((e) => e.components.includes(exported)) : [];
|
|
129
|
+
if (exported && (k.props[exported] || pages.length)) {
|
|
130
|
+
const info = kitInfo(k, kit);
|
|
131
|
+
const rank = (e) => (norm(e.name).startsWith(n) ? 0 : 1000) +
|
|
132
|
+
(e.path.startsWith("patterns/") ? 500 : 0) +
|
|
133
|
+
e.components.length;
|
|
134
|
+
const ranked = [...pages].sort((a, b) => rank(a) - rank(b));
|
|
135
|
+
const best = ranked[0];
|
|
136
|
+
const out = [
|
|
137
|
+
`# ${exported}`,
|
|
138
|
+
"",
|
|
139
|
+
`> \`${exported}\` has no page of its own${best ? `; it is shown on ${ranked.length} page${ranked.length === 1 ? "" : "s"}, the example below is from ${best.title}` : ""}.`,
|
|
140
|
+
"",
|
|
141
|
+
`Component · ${info.label} kit${best ? ` · [Live page](${pageUrl(k, kit, best.path)})` : ""}`,
|
|
142
|
+
"",
|
|
143
|
+
"## Import",
|
|
144
|
+
"",
|
|
145
|
+
"```tsx",
|
|
146
|
+
`import { ${exported} } from "${info.package}";`,
|
|
147
|
+
"```",
|
|
148
|
+
"",
|
|
149
|
+
];
|
|
150
|
+
const props = propsMarkdown(k, exported);
|
|
151
|
+
if (props)
|
|
152
|
+
out.push("## Props", "", props.replace(/^### .*\n\n/, ""), "");
|
|
153
|
+
if (best)
|
|
154
|
+
out.push(`## Example (${best.title})`, "", "```tsx", appCode(best.code, kit).trim(), "```", "");
|
|
155
|
+
if (ranked.length > 1) {
|
|
156
|
+
out.push("## Pages that show it", "");
|
|
157
|
+
for (const e of ranked.slice(0, 8))
|
|
158
|
+
out.push(`- ${e.name}: ${e.summary} (${e.path.startsWith("patterns/") ? "get_pattern" : "get_component"})`);
|
|
159
|
+
out.push("");
|
|
160
|
+
}
|
|
161
|
+
return out.join("\n").trim() + "\n";
|
|
162
|
+
}
|
|
163
|
+
const e = findEntry(k, name);
|
|
164
|
+
return e ? entryWithImport(k, e, kit) : undefined;
|
|
165
|
+
}
|
|
166
|
+
function findPattern(k, name) {
|
|
167
|
+
const n = norm(name);
|
|
168
|
+
const hit = k.patterns.find((e) => norm(e.name) === n || norm(e.title) === n || norm(e.name) === `${n}screen`);
|
|
169
|
+
if (hit)
|
|
170
|
+
return hit;
|
|
171
|
+
const e = findEntry(k, name);
|
|
172
|
+
return e && e.path.startsWith("patterns/") ? e : undefined;
|
|
173
|
+
}
|
|
174
|
+
// ── server ──
|
|
175
|
+
export function createServer(k, options) {
|
|
176
|
+
const defaultKit = options.kit;
|
|
177
|
+
const groups = [...new Set(k.components.map((c) => c.group))];
|
|
178
|
+
const guideSlugs = k.guides.map((g) => g.slug);
|
|
179
|
+
const tokenGroups = TOKEN_GROUPS.map((g) => g.group);
|
|
180
|
+
const info = kitInfo(k, defaultKit);
|
|
181
|
+
const kitProp = {
|
|
182
|
+
type: "string",
|
|
183
|
+
enum: ["halo", "pro"],
|
|
184
|
+
description: `Kit whose package name the code imports from. Defaults to "${defaultKit}". Both kits have the same components and props.`,
|
|
185
|
+
};
|
|
186
|
+
const annotations = (title) => ({ title, readOnlyHint: true, openWorldHint: false });
|
|
187
|
+
const tool = (name, title, description, properties, required = []) => ({
|
|
188
|
+
name,
|
|
189
|
+
title,
|
|
190
|
+
description,
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties,
|
|
194
|
+
...(required.length ? { required } : {}),
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
},
|
|
197
|
+
annotations: annotations(title),
|
|
198
|
+
});
|
|
199
|
+
const tools = [
|
|
200
|
+
tool("search_docs", "Search the docs", `Search the ${PRODUCT} docs: components, patterns (full screens), guides, design tokens and icons. Returns ranked hits with the tool to call next. Use it whenever you are unsure which component fits a job (e.g. "approval", "date range", "file upload").`, {
|
|
201
|
+
query: { type: "string", description: "What you are looking for, in plain words." },
|
|
202
|
+
limit: {
|
|
203
|
+
type: "integer",
|
|
204
|
+
minimum: 1,
|
|
205
|
+
maximum: 50,
|
|
206
|
+
description: "Maximum number of hits (default 10).",
|
|
207
|
+
},
|
|
208
|
+
}, ["query"]),
|
|
209
|
+
tool("list_components", "List components", `List every ${PRODUCT} component page by group with a one-line summary, and the patterns (complete screens). Use it to get an overview before building.`, {
|
|
210
|
+
group: {
|
|
211
|
+
type: "string",
|
|
212
|
+
enum: [...groups, "Patterns"],
|
|
213
|
+
description: "Only this group. Omit for all.",
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
tool("get_component", "Get a component", `Get the docs of a ${PRODUCT} component: description, import line, example code and the props of every component on the page. Accepts a page name ("Tabs"), a title ("Date picker") or any component it documents ("TabPanel").`, {
|
|
217
|
+
name: { type: "string", description: 'Component name, e.g. "Button" or "ApprovalCard".' },
|
|
218
|
+
kit: kitProp,
|
|
219
|
+
}, ["name"]),
|
|
220
|
+
tool("get_pattern", "Get a pattern", `Get a ${PRODUCT} pattern: a complete screen built from kit components (dashboard, settings, inbox, pricing…) with its full code. A good starting point for a new screen.`, {
|
|
221
|
+
name: {
|
|
222
|
+
type: "string",
|
|
223
|
+
description: `Pattern name, one of: ${k.patterns.map((p) => p.name).join(", ")}.`,
|
|
224
|
+
},
|
|
225
|
+
kit: kitProp,
|
|
226
|
+
}, ["name"]),
|
|
227
|
+
tool("get_guide", "Get a guide", `Get a guide of the ${PRODUCT} docs: introduction, installation, CLI, theming (with all tokens), AI agents and MCP, status and approvals, changelog.`, {
|
|
228
|
+
topic: { type: "string", enum: guideSlugs, description: "The guide." },
|
|
229
|
+
kit: kitProp,
|
|
230
|
+
}, ["topic"]),
|
|
231
|
+
tool("get_tokens", "Get design tokens", `Get the design tokens (CSS custom properties) with their Halo and Pro values and what each is for. Use tokens instead of colour literals or arbitrary values.`, {
|
|
232
|
+
group: {
|
|
233
|
+
type: "string",
|
|
234
|
+
enum: tokenGroups,
|
|
235
|
+
description: "Only this group. Omit for all.",
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
tool("list_icons", "List icons", `List every valid icon name for \`<Icon name>\` and the \`icon\` props of ${PRODUCT} components.`, {}),
|
|
239
|
+
tool("check_usage", "Check code", `Check code that uses ${PRODUCT}: unknown components and props, missing required props, unknown icon names, colour literals and arbitrary Tailwind values. Run it on new or changed code before finishing. Heuristic, not a type checker.`, { code: { type: "string", description: "The TSX/JSX source to check." } }, ["code"]),
|
|
240
|
+
];
|
|
241
|
+
const prompts = [
|
|
242
|
+
{
|
|
243
|
+
name: "build_screen",
|
|
244
|
+
title: "Build a screen",
|
|
245
|
+
description: `Build a screen with ${PRODUCT} components, starting from a pattern where one fits.`,
|
|
246
|
+
arguments: [
|
|
247
|
+
{
|
|
248
|
+
name: "description",
|
|
249
|
+
description: "What the screen shows and does.",
|
|
250
|
+
required: true,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "review_ui",
|
|
256
|
+
title: "Review UI code",
|
|
257
|
+
description: `Review code against the ${PRODUCT} rules and run check_usage on it.`,
|
|
258
|
+
arguments: [{ name: "code", description: "The code to review.", required: true }],
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
// ── tools ──
|
|
262
|
+
const runTool = {
|
|
263
|
+
search_docs(args) {
|
|
264
|
+
const query = stringArg(args, "query", true);
|
|
265
|
+
const rawLimit = args.limit;
|
|
266
|
+
if (rawLimit !== undefined && (typeof rawLimit !== "number" || !Number.isFinite(rawLimit)))
|
|
267
|
+
throw new ToolError('"limit" must be a number.');
|
|
268
|
+
const limit = Math.min(50, Math.max(1, Math.round(rawLimit ?? 10)));
|
|
269
|
+
const hits = search(k, query, limit);
|
|
270
|
+
if (!hits.length)
|
|
271
|
+
return `No results for "${query}". Try other or fewer words, or call list_components for an overview.`;
|
|
272
|
+
const next = (kind, name) => {
|
|
273
|
+
switch (kind) {
|
|
274
|
+
case "component":
|
|
275
|
+
return `get_component ${json({ name })}`;
|
|
276
|
+
case "pattern":
|
|
277
|
+
return `get_pattern ${json({ name })}`;
|
|
278
|
+
case "guide":
|
|
279
|
+
return `get_guide ${json({ topic: name })}`;
|
|
280
|
+
case "token": {
|
|
281
|
+
const group = tokenGroupOf(k, name);
|
|
282
|
+
return `get_tokens ${json(group ? { group } : {})}`;
|
|
283
|
+
}
|
|
284
|
+
default:
|
|
285
|
+
return `use <Icon name="${name}" /> or icon="${name}"`;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
return [
|
|
289
|
+
`${hits.length} result${hits.length === 1 ? "" : "s"} for "${query}":`,
|
|
290
|
+
"",
|
|
291
|
+
...hits.map((h, i) => `${i + 1}. [${h.kind}] ${h.title}${h.summary ? `: ${h.summary}` : ""}\n → ${next(h.kind, h.name)}`),
|
|
292
|
+
"",
|
|
293
|
+
"Call the tool after the arrow for the full docs, example code and props.",
|
|
294
|
+
].join("\n");
|
|
295
|
+
},
|
|
296
|
+
list_components(args) {
|
|
297
|
+
const want = stringArg(args, "group");
|
|
298
|
+
const wantNorm = want === undefined ? undefined : norm(want);
|
|
299
|
+
const isPatterns = wantNorm === "patterns" || wantNorm === "pattern" || wantNorm === "pages";
|
|
300
|
+
const shown = want === undefined ? groups : groups.filter((g) => norm(g) === wantNorm);
|
|
301
|
+
if (want !== undefined && !isPatterns && !shown.length)
|
|
302
|
+
throw new ToolError(`Unknown group "${want}". Groups: ${[...groups, "Patterns"].join(", ")}.`);
|
|
303
|
+
const out = [];
|
|
304
|
+
if (!isPatterns) {
|
|
305
|
+
if (want === undefined)
|
|
306
|
+
out.push(`# ${PRODUCT} components`, "", `${k.components.length} component pages and ${k.patterns.length} patterns. Call get_component with a name for its example and props.`, "");
|
|
307
|
+
for (const g of shown) {
|
|
308
|
+
out.push(`## ${g}`, "");
|
|
309
|
+
for (const e of k.components.filter((c) => c.group === g)) {
|
|
310
|
+
const parts = e.components.length > 1 ? ` (${e.components.join(", ")})` : "";
|
|
311
|
+
out.push(`- ${e.name}${parts}: ${e.summary}`);
|
|
312
|
+
}
|
|
313
|
+
out.push("");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (want === undefined || isPatterns) {
|
|
317
|
+
out.push("## Patterns", "", "Complete screens; call get_pattern with a name for the code.", "");
|
|
318
|
+
for (const e of k.patterns)
|
|
319
|
+
out.push(`- ${e.name}: ${e.summary}`);
|
|
320
|
+
out.push("");
|
|
321
|
+
}
|
|
322
|
+
return out.join("\n");
|
|
323
|
+
},
|
|
324
|
+
get_component(args) {
|
|
325
|
+
const name = stringArg(args, "name", true);
|
|
326
|
+
const kit = kitArg(args, defaultKit);
|
|
327
|
+
const doc = componentDoc(k, name, kit);
|
|
328
|
+
if (!doc) {
|
|
329
|
+
const near = closest(k, name, ["component", "pattern"]);
|
|
330
|
+
throw new ToolError(`No component named "${name}".${near ? ` Closest matches:\n${near}` : " Call list_components for all names or search_docs to search."}`);
|
|
331
|
+
}
|
|
332
|
+
return doc;
|
|
333
|
+
},
|
|
334
|
+
get_pattern(args) {
|
|
335
|
+
const name = stringArg(args, "name", true);
|
|
336
|
+
const kit = kitArg(args, defaultKit);
|
|
337
|
+
const e = findPattern(k, name);
|
|
338
|
+
if (!e) {
|
|
339
|
+
const comp = findEntry(k, name);
|
|
340
|
+
const hint = comp && !comp.path.startsWith("patterns/")
|
|
341
|
+
? ` "${comp.name}" is a component: call get_component.`
|
|
342
|
+
: "";
|
|
343
|
+
throw new ToolError(`No pattern named "${name}".${hint} Patterns: ${k.patterns.map((p) => p.name).join(", ")}.`);
|
|
344
|
+
}
|
|
345
|
+
return entryWithImport(k, e, kit);
|
|
346
|
+
},
|
|
347
|
+
get_guide(args) {
|
|
348
|
+
const topic = stringArg(args, "topic", true);
|
|
349
|
+
const kit = kitArg(args, defaultKit);
|
|
350
|
+
const g = findGuide(k, topic);
|
|
351
|
+
if (!g)
|
|
352
|
+
throw new ToolError(`No guide "${topic}". Guides: ${guideSlugs.join(", ")}.`);
|
|
353
|
+
return guideMarkdown(k, g, kit);
|
|
354
|
+
},
|
|
355
|
+
get_tokens(args) {
|
|
356
|
+
const group = stringArg(args, "group");
|
|
357
|
+
if (group !== undefined && !tokenGroups.includes(group))
|
|
358
|
+
throw new ToolError(`Unknown token group "${group}". Groups: ${tokenGroups.join(", ")}.`);
|
|
359
|
+
return [
|
|
360
|
+
`# Design tokens${group ? `: ${TOKEN_GROUPS.find((g) => g.group === group)?.title}` : ""}`,
|
|
361
|
+
"",
|
|
362
|
+
"> CSS custom properties, the same names in both kits. Use them as `var(--name)` in CSS or through the Tailwind preset (`bg-primary`, `text-muted-foreground`, `bg-card`); never colour literals or arbitrary values. Values with a slash are light / dark.",
|
|
363
|
+
"",
|
|
364
|
+
tokensMarkdown(k, group),
|
|
365
|
+
].join("\n");
|
|
366
|
+
},
|
|
367
|
+
list_icons() {
|
|
368
|
+
return [
|
|
369
|
+
`${k.icons.length} icons. Use them as \`<Icon name="…" />\` or in the \`icon\` prop of components; icon-only buttons need a \`label\`.`,
|
|
370
|
+
"",
|
|
371
|
+
k.icons.join(", "),
|
|
372
|
+
].join("\n");
|
|
373
|
+
},
|
|
374
|
+
check_usage(args) {
|
|
375
|
+
const code = stringArg(args, "code", true);
|
|
376
|
+
const findings = checkUsage(k, code);
|
|
377
|
+
if (!findings.length)
|
|
378
|
+
return "No problems found: components, props, icons and styling look right. (This is a heuristic check; still run the type checker.)";
|
|
379
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
380
|
+
const warnings = findings.length - errors;
|
|
381
|
+
const count = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
382
|
+
return [
|
|
383
|
+
`${count(errors, "error")}, ${count(warnings, "warning")}:`,
|
|
384
|
+
"",
|
|
385
|
+
...findings.map((f) => `- Line ${f.line} · ${f.severity}: ${f.message}`),
|
|
386
|
+
"",
|
|
387
|
+
"Fix these, then run check_usage again. get_component shows the props of a component.",
|
|
388
|
+
].join("\n");
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
function callTool(params) {
|
|
392
|
+
const name = params.name;
|
|
393
|
+
if (typeof name !== "string")
|
|
394
|
+
throw new RpcError(ErrorCode.InvalidParams, "Missing tool name");
|
|
395
|
+
const fn = Object.hasOwn(runTool, name) ? runTool[name] : undefined;
|
|
396
|
+
if (!fn)
|
|
397
|
+
throw new RpcError(ErrorCode.InvalidParams, `Unknown tool: ${name}. Tools: ${tools.map((t) => t.name).join(", ")}`);
|
|
398
|
+
const args = params.arguments ?? {};
|
|
399
|
+
if (!isObject(args))
|
|
400
|
+
throw new RpcError(ErrorCode.InvalidParams, "Tool arguments must be an object");
|
|
401
|
+
try {
|
|
402
|
+
return text(fn(args));
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
if (e instanceof ToolError)
|
|
406
|
+
return { ...text(e.message), isError: true };
|
|
407
|
+
throw e;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const resources = [
|
|
411
|
+
{
|
|
412
|
+
uri: `${SCHEME}llms-full`,
|
|
413
|
+
name: "llms-full",
|
|
414
|
+
title: `${PRODUCT}: complete docs`,
|
|
415
|
+
description: "Every guide and component page with examples and props in one file.",
|
|
416
|
+
mimeType: MARKDOWN,
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
uri: `${SCHEME}components`,
|
|
420
|
+
name: "components",
|
|
421
|
+
title: "All components",
|
|
422
|
+
description: "Every component by group with its summary, the patterns and all icon names.",
|
|
423
|
+
mimeType: MARKDOWN,
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
uri: `${SCHEME}tokens`,
|
|
427
|
+
name: "tokens",
|
|
428
|
+
title: "Design tokens",
|
|
429
|
+
description: "Every token with its Halo and Pro value.",
|
|
430
|
+
mimeType: MARKDOWN,
|
|
431
|
+
},
|
|
432
|
+
...k.guides.map((g) => ({
|
|
433
|
+
uri: `${SCHEME}guides/${g.slug}`,
|
|
434
|
+
name: `guides/${g.slug}`,
|
|
435
|
+
title: g.title,
|
|
436
|
+
description: plain(forKit(k, g.description, defaultKit)),
|
|
437
|
+
mimeType: MARKDOWN,
|
|
438
|
+
})),
|
|
439
|
+
...allEntries(k).map((e) => ({
|
|
440
|
+
uri: `${SCHEME}${e.path}`,
|
|
441
|
+
name: e.path,
|
|
442
|
+
title: e.title,
|
|
443
|
+
description: e.summary,
|
|
444
|
+
mimeType: MARKDOWN,
|
|
445
|
+
})),
|
|
446
|
+
];
|
|
447
|
+
const templates = [
|
|
448
|
+
{
|
|
449
|
+
uriTemplate: `${SCHEME}components/{name}`,
|
|
450
|
+
name: "component",
|
|
451
|
+
title: "Component docs",
|
|
452
|
+
description: 'Docs of a component by name, e.g. "Button" or "TabPanel".',
|
|
453
|
+
mimeType: MARKDOWN,
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
uriTemplate: `${SCHEME}patterns/{name}`,
|
|
457
|
+
name: "pattern",
|
|
458
|
+
title: "Pattern",
|
|
459
|
+
description: "A complete screen with its code.",
|
|
460
|
+
mimeType: MARKDOWN,
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
uriTemplate: `${SCHEME}guides/{slug}`,
|
|
464
|
+
name: "guide",
|
|
465
|
+
title: "Guide",
|
|
466
|
+
description: `One of: ${guideSlugs.join(", ")}.`,
|
|
467
|
+
mimeType: MARKDOWN,
|
|
468
|
+
},
|
|
469
|
+
];
|
|
470
|
+
function readResource(uri) {
|
|
471
|
+
if (!uri.startsWith(SCHEME))
|
|
472
|
+
throw notFound(uri);
|
|
473
|
+
const path = uri.slice(SCHEME.length).replace(/\/+$/, "");
|
|
474
|
+
const [kind, ...rest] = path.split("/");
|
|
475
|
+
let name = rest.join("/");
|
|
476
|
+
try {
|
|
477
|
+
name = decodeURIComponent(name);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
throw notFound(uri);
|
|
481
|
+
}
|
|
482
|
+
switch (kind) {
|
|
483
|
+
case "llms-full":
|
|
484
|
+
return llmsFull(k);
|
|
485
|
+
case "tokens":
|
|
486
|
+
return runTool.get_tokens?.({}) ?? "";
|
|
487
|
+
case "components": {
|
|
488
|
+
if (!name)
|
|
489
|
+
return componentsIndexMarkdown(k, defaultKit);
|
|
490
|
+
const doc = componentDoc(k, name, defaultKit);
|
|
491
|
+
if (doc)
|
|
492
|
+
return doc;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case "patterns": {
|
|
496
|
+
const e = name ? findPattern(k, name) : undefined;
|
|
497
|
+
if (e)
|
|
498
|
+
return entryWithImport(k, e, defaultKit);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
case "guides": {
|
|
502
|
+
const g = name ? findGuide(k, name) : undefined;
|
|
503
|
+
if (g)
|
|
504
|
+
return guideMarkdown(k, g, defaultKit);
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
throw notFound(uri);
|
|
509
|
+
}
|
|
510
|
+
const notFound = (uri) => new RpcError(ErrorCode.ResourceNotFound, `Resource not found: ${uri}`, { uri });
|
|
511
|
+
// ── prompts ──
|
|
512
|
+
function getPrompt(params) {
|
|
513
|
+
const name = params.name;
|
|
514
|
+
const args = isObject(params.arguments) ? params.arguments : {};
|
|
515
|
+
const arg = (key) => {
|
|
516
|
+
const v = args[key];
|
|
517
|
+
if (typeof v !== "string" || !v.trim())
|
|
518
|
+
throw new RpcError(ErrorCode.InvalidParams, `Missing the required argument "${key}"`);
|
|
519
|
+
return v;
|
|
520
|
+
};
|
|
521
|
+
const message = (t) => ({
|
|
522
|
+
role: "user",
|
|
523
|
+
content: { type: "text", text: t },
|
|
524
|
+
});
|
|
525
|
+
if (name === "build_screen") {
|
|
526
|
+
const description = arg("description");
|
|
527
|
+
return {
|
|
528
|
+
description: prompts[0]?.description,
|
|
529
|
+
messages: [
|
|
530
|
+
message([
|
|
531
|
+
`Build this screen with ${PRODUCT} (${info.label} kit, \`${info.package}\`):`,
|
|
532
|
+
"",
|
|
533
|
+
description,
|
|
534
|
+
"",
|
|
535
|
+
"Work like this:",
|
|
536
|
+
"1. Plan the screen: list its regions and the UI parts each needs.",
|
|
537
|
+
"2. Look for a pattern that is close (list_components with group Patterns, or search_docs) and start from its code with get_pattern.",
|
|
538
|
+
"3. For every part, find the component with search_docs and read its props and example with get_component. Do not guess names or props.",
|
|
539
|
+
"4. Write the code with kit components, tokens and the formatters; no custom markup where a component exists.",
|
|
540
|
+
"5. Run check_usage on the finished code and fix every finding.",
|
|
541
|
+
"",
|
|
542
|
+
"Rules:",
|
|
543
|
+
agentRules(defaultKit),
|
|
544
|
+
].join("\n")),
|
|
545
|
+
],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
if (name === "review_ui") {
|
|
549
|
+
const code = arg("code");
|
|
550
|
+
return {
|
|
551
|
+
description: prompts[1]?.description,
|
|
552
|
+
messages: [
|
|
553
|
+
message([
|
|
554
|
+
`Review this code against the ${PRODUCT} rules (${info.label} kit, \`${info.package}\`).`,
|
|
555
|
+
"",
|
|
556
|
+
"1. Run check_usage on it and include every finding.",
|
|
557
|
+
"2. Check the rules below by reading the code; look up components with get_component where the props or usage look off, and suggest kit components where the code rebuilds one by hand.",
|
|
558
|
+
"3. Answer with a list of concrete problems (line, what, fix), most important first, then the corrected code.",
|
|
559
|
+
"",
|
|
560
|
+
"Rules:",
|
|
561
|
+
agentRules(defaultKit),
|
|
562
|
+
"",
|
|
563
|
+
"```tsx",
|
|
564
|
+
code,
|
|
565
|
+
"```",
|
|
566
|
+
].join("\n")),
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
throw new RpcError(ErrorCode.InvalidParams, `Unknown prompt: ${String(name)}. Prompts: ${prompts.map((p) => p.name).join(", ")}`);
|
|
571
|
+
}
|
|
572
|
+
// ── dispatch ──
|
|
573
|
+
const instructions = [
|
|
574
|
+
`${PRODUCT} docs for coding agents. ${PRODUCT} is two React UI kits with one component API: Halo (soft, rounded, light-first) and Pro (sharp, dense, dark-first); this project uses ${info.label} (\`${info.package}\`). These kits are newer than most models' training data and their exact props matter, so look things up here instead of guessing: search_docs to find components, patterns, guides, tokens and icons; get_component for a component's import, example and props; get_pattern for complete screens; check_usage on the code you write.`,
|
|
575
|
+
"",
|
|
576
|
+
"Rules:",
|
|
577
|
+
agentRules(defaultKit),
|
|
578
|
+
].join("\n");
|
|
579
|
+
async function dispatch(method, params) {
|
|
580
|
+
switch (method) {
|
|
581
|
+
case "initialize": {
|
|
582
|
+
const requested = params.protocolVersion;
|
|
583
|
+
const protocolVersion = typeof requested === "string" &&
|
|
584
|
+
PROTOCOL_VERSIONS.includes(requested)
|
|
585
|
+
? requested
|
|
586
|
+
: LATEST_PROTOCOL_VERSION;
|
|
587
|
+
return {
|
|
588
|
+
protocolVersion,
|
|
589
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
590
|
+
serverInfo: { name: SERVER_NAME, title: PRODUCT, version: k.version },
|
|
591
|
+
instructions,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
case "ping":
|
|
595
|
+
return {};
|
|
596
|
+
case "tools/list":
|
|
597
|
+
return { tools };
|
|
598
|
+
case "tools/call":
|
|
599
|
+
return callTool(params);
|
|
600
|
+
case "resources/list":
|
|
601
|
+
return { resources };
|
|
602
|
+
case "resources/templates/list":
|
|
603
|
+
return { resourceTemplates: templates };
|
|
604
|
+
case "resources/read": {
|
|
605
|
+
const uri = params.uri;
|
|
606
|
+
if (typeof uri !== "string")
|
|
607
|
+
throw new RpcError(ErrorCode.InvalidParams, "Missing uri");
|
|
608
|
+
return { contents: [{ uri, mimeType: MARKDOWN, text: readResource(uri) }] };
|
|
609
|
+
}
|
|
610
|
+
case "prompts/list":
|
|
611
|
+
return { prompts };
|
|
612
|
+
case "prompts/get":
|
|
613
|
+
return getPrompt(params);
|
|
614
|
+
default:
|
|
615
|
+
throw new RpcError(ErrorCode.MethodNotFound, `Method not found: ${method}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
async handle(message) {
|
|
620
|
+
if (!isObject(message) || message.jsonrpc !== "2.0")
|
|
621
|
+
return rpcError(null, ErrorCode.InvalidRequest, "Invalid JSON-RPC 2.0 message");
|
|
622
|
+
const hasId = "id" in message && message.id !== undefined;
|
|
623
|
+
const id = (hasId ? message.id : null);
|
|
624
|
+
if (typeof message.method !== "string") {
|
|
625
|
+
// a response to a request we never sent, or garbage
|
|
626
|
+
return hasId && !("result" in message || "error" in message)
|
|
627
|
+
? rpcError(id, ErrorCode.InvalidRequest, "Missing method")
|
|
628
|
+
: null;
|
|
629
|
+
}
|
|
630
|
+
if (!hasId)
|
|
631
|
+
return null; // notification: initialized, cancelled, …
|
|
632
|
+
if (typeof id !== "string" && typeof id !== "number")
|
|
633
|
+
return rpcError(null, ErrorCode.InvalidRequest, "Invalid id");
|
|
634
|
+
const params = message.params ?? {};
|
|
635
|
+
if (!isObject(params))
|
|
636
|
+
return rpcError(id, ErrorCode.InvalidParams, "params must be an object");
|
|
637
|
+
try {
|
|
638
|
+
return { jsonrpc: "2.0", id, result: await dispatch(message.method, params) };
|
|
639
|
+
}
|
|
640
|
+
catch (e) {
|
|
641
|
+
if (e instanceof RpcError)
|
|
642
|
+
return rpcError(id, e.code, e.message, e.data);
|
|
643
|
+
return rpcError(id, ErrorCode.InternalError, e.message ?? String(e));
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function rpcError(id, code, message, data) {
|
|
649
|
+
return {
|
|
650
|
+
jsonrpc: "2.0",
|
|
651
|
+
id,
|
|
652
|
+
error: data === undefined ? { code, message } : { code, message, data },
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// ── IO ──
|
|
656
|
+
/** Loads the docs bundled with the CLI (dist/knowledge.json). */
|
|
657
|
+
export function loadKnowledge(url = new URL("./knowledge.json", import.meta.url)) {
|
|
658
|
+
let raw;
|
|
659
|
+
try {
|
|
660
|
+
raw = readFileSync(url, "utf8");
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
throw new Error(`The bundled docs are missing (${url.pathname}). Reinstall @twentyfirstmedia/ui, or in the repo run \`pnpm --filter @twentyfirstmedia/ui build\`.`);
|
|
664
|
+
}
|
|
665
|
+
return JSON.parse(raw);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Serves newline-delimited JSON-RPC over stdin/stdout. Messages are answered in order; the
|
|
669
|
+
* promise resolves when the input ends and every answer is written, or when the client closes the
|
|
670
|
+
* output (EPIPE): then there is nobody left to answer, so the server stops quietly.
|
|
671
|
+
*/
|
|
672
|
+
export function serveStdio(server, io = {}) {
|
|
673
|
+
const input = io.input ?? process.stdin;
|
|
674
|
+
const output = io.output ?? process.stdout;
|
|
675
|
+
const logError = io.onError ?? ((m) => process.stderr.write(`${m}\n`));
|
|
676
|
+
let buffer = "";
|
|
677
|
+
let queue = Promise.resolve();
|
|
678
|
+
let closed = false;
|
|
679
|
+
const send = (v) => new Promise((resolve) => {
|
|
680
|
+
if (closed)
|
|
681
|
+
return resolve();
|
|
682
|
+
output.write(`${JSON.stringify(v)}\n`, () => resolve());
|
|
683
|
+
});
|
|
684
|
+
const processLine = async (line) => {
|
|
685
|
+
let msg;
|
|
686
|
+
try {
|
|
687
|
+
msg = JSON.parse(line);
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
await send(rpcError(null, ErrorCode.ParseError, "Parse error"));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
if (Array.isArray(msg)) {
|
|
695
|
+
// JSON-RPC batch (allowed by protocol version 2025-03-26)
|
|
696
|
+
if (!msg.length) {
|
|
697
|
+
await send(rpcError(null, ErrorCode.InvalidRequest, "Empty batch"));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const answers = (await Promise.all(msg.map((m) => server.handle(m)))).filter((a) => a !== null);
|
|
701
|
+
if (answers.length)
|
|
702
|
+
await send(answers);
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
const answer = await server.handle(msg);
|
|
706
|
+
if (answer)
|
|
707
|
+
await send(answer);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch (e) {
|
|
711
|
+
logError(`twentyfirst-ui mcp: ${e.stack ?? String(e)}`);
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
const flush = (final) => {
|
|
715
|
+
if (closed)
|
|
716
|
+
return;
|
|
717
|
+
let i;
|
|
718
|
+
while ((i = buffer.indexOf("\n")) >= 0) {
|
|
719
|
+
const line = buffer.slice(0, i).replace(/\r$/, "").trim();
|
|
720
|
+
buffer = buffer.slice(i + 1);
|
|
721
|
+
if (line)
|
|
722
|
+
queue = queue.then(() => processLine(line));
|
|
723
|
+
}
|
|
724
|
+
if (final && buffer.trim()) {
|
|
725
|
+
const line = buffer.trim();
|
|
726
|
+
buffer = "";
|
|
727
|
+
queue = queue.then(() => processLine(line));
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
return new Promise((resolve) => {
|
|
731
|
+
let done = false;
|
|
732
|
+
const finish = () => {
|
|
733
|
+
if (done)
|
|
734
|
+
return;
|
|
735
|
+
done = true;
|
|
736
|
+
flush(true);
|
|
737
|
+
void queue.then(resolve);
|
|
738
|
+
};
|
|
739
|
+
const decoder = new TextDecoder();
|
|
740
|
+
input.on("data", (chunk) => {
|
|
741
|
+
buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
742
|
+
flush(false);
|
|
743
|
+
});
|
|
744
|
+
input.on("end", finish);
|
|
745
|
+
input.on("close", finish);
|
|
746
|
+
input.on("error", (e) => {
|
|
747
|
+
logError(`twentyfirst-ui mcp: ${e.message}`);
|
|
748
|
+
finish();
|
|
749
|
+
});
|
|
750
|
+
// the client went away: drop what is pending and end without an unhandled 'error'
|
|
751
|
+
const stop = () => {
|
|
752
|
+
if (closed)
|
|
753
|
+
return;
|
|
754
|
+
closed = true;
|
|
755
|
+
buffer = "";
|
|
756
|
+
done = true;
|
|
757
|
+
input.removeAllListeners("data");
|
|
758
|
+
resolve();
|
|
759
|
+
};
|
|
760
|
+
output.on("error", (e) => {
|
|
761
|
+
if (e.code !== "EPIPE" && e.code !== "ERR_STREAM_DESTROYED")
|
|
762
|
+
logError(`twentyfirst-ui mcp: ${e.message}`);
|
|
763
|
+
stop();
|
|
764
|
+
});
|
|
765
|
+
output.on("close", stop);
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
//# sourceMappingURL=mcp.js.map
|