@nyuchi/mzizi-mcp 0.13.0 → 0.15.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.
Files changed (73) hide show
  1. package/README.md +197 -26
  2. package/dist/auth/front.d.ts +8 -0
  3. package/dist/auth/front.d.ts.map +1 -1
  4. package/dist/auth/front.js +10 -0
  5. package/dist/auth/front.js.map +1 -1
  6. package/dist/auth/mcp-api-handler.d.ts +1 -1
  7. package/dist/auth/mcp-api-handler.d.ts.map +1 -1
  8. package/dist/auth/mcp-api-handler.js +4 -3
  9. package/dist/auth/mcp-api-handler.js.map +1 -1
  10. package/dist/auth/provider-options.d.ts +16 -0
  11. package/dist/auth/provider-options.d.ts.map +1 -0
  12. package/dist/auth/provider-options.js +21 -0
  13. package/dist/auth/provider-options.js.map +1 -0
  14. package/dist/docs-federation.d.ts +1 -1
  15. package/dist/docs-federation.d.ts.map +1 -1
  16. package/dist/docs-federation.js +1 -0
  17. package/dist/docs-federation.js.map +1 -1
  18. package/dist/env.d.ts +1 -0
  19. package/dist/env.d.ts.map +1 -1
  20. package/dist/floors.d.ts +117 -0
  21. package/dist/floors.d.ts.map +1 -0
  22. package/dist/floors.js +103 -0
  23. package/dist/floors.js.map +1 -0
  24. package/dist/fundi-client.d.ts +6 -0
  25. package/dist/fundi-client.d.ts.map +1 -1
  26. package/dist/fundi-client.js +14 -3
  27. package/dist/fundi-client.js.map +1 -1
  28. package/dist/generated/registry-pin.d.ts +8 -1
  29. package/dist/generated/registry-pin.d.ts.map +1 -1
  30. package/dist/generated/registry-pin.js +3 -1
  31. package/dist/generated/registry-pin.js.map +1 -1
  32. package/dist/generated/registry.d.ts +2 -2
  33. package/dist/generated/registry.d.ts.map +1 -1
  34. package/dist/generated/registry.js +381 -325
  35. package/dist/generated/registry.js.map +1 -1
  36. package/dist/generated/skills.d.ts +11 -1
  37. package/dist/generated/skills.d.ts.map +1 -1
  38. package/dist/generated/skills.js +216 -21
  39. package/dist/generated/skills.js.map +1 -1
  40. package/dist/http.d.ts +32 -2
  41. package/dist/http.d.ts.map +1 -1
  42. package/dist/http.js +109 -28
  43. package/dist/http.js.map +1 -1
  44. package/dist/limits.d.ts +32 -0
  45. package/dist/limits.d.ts.map +1 -0
  46. package/dist/limits.js +44 -0
  47. package/dist/limits.js.map +1 -0
  48. package/dist/origin.d.ts +55 -0
  49. package/dist/origin.d.ts.map +1 -0
  50. package/dist/origin.js +127 -0
  51. package/dist/origin.js.map +1 -0
  52. package/dist/rate-limit.d.ts +1 -1
  53. package/dist/rate-limit.d.ts.map +1 -1
  54. package/dist/rate-limit.js +3 -2
  55. package/dist/rate-limit.js.map +1 -1
  56. package/dist/registry.d.ts +1 -1
  57. package/dist/registry.d.ts.map +1 -1
  58. package/dist/registry.js +22 -1
  59. package/dist/registry.js.map +1 -1
  60. package/dist/server.d.ts +83 -4
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +109 -10
  63. package/dist/server.js.map +1 -1
  64. package/dist/stdio.js +5 -4
  65. package/dist/stdio.js.map +1 -1
  66. package/dist/tools.d.ts +21 -1
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +413 -114
  69. package/dist/tools.js.map +1 -1
  70. package/dist/worker.d.ts.map +1 -1
  71. package/dist/worker.js +19 -8
  72. package/dist/worker.js.map +1 -1
  73. package/package.json +18 -17
package/dist/tools.js CHANGED
@@ -25,7 +25,24 @@ import { bundledRegistry } from "./registry.js";
25
25
  import { dispatchFundiA2A, FUNDI_SUBMIT_TOOL, FUNDI_STATUS_TOOL, FUNDI_CANCEL_TOOL, } from "./fundi-a2a.js";
26
26
  import { jsonContent, toolError } from "./tool-result.js";
27
27
  import { signInRequired } from "./access.js";
28
+ import { CHARACTER_LIMIT, fitToLimit } from "./limits.js";
29
+ import { REGISTRY_SPACING_FLOOR } from "./generated/registry-pin.js";
30
+ import { SPACING_FLOOR_MISSING, checkSpacing, describeFloors, spacingFloorFrom, spacingFloorPhrase } from "./floors.js";
31
+ /** The spacing floor in words, from the pinned registry's numbers (no number when the pin predates it). */
32
+ const SPACING_PHRASE = spacingFloorPhrase(REGISTRY_SPACING_FLOOR);
28
33
  import { BUNDLED_SKILLS, SKILL_CATEGORIES, SKILLS_VERSION } from "./generated/skills.js";
34
+ /** The MCP resource URI of a skill's SKILL.md body, and of a file it ships. */
35
+ export const skillUri = (name) => `mzizi://skills/${name}`;
36
+ export const skillFileUri = (name, path) => `mzizi://skills/${name}/${path}`;
37
+ /** A skill as the tool returns it: the body in full, its files as a list (content on request). */
38
+ export function skillView(skill) {
39
+ const { files, ...rest } = skill;
40
+ return {
41
+ ...rest,
42
+ uri: skillUri(skill.name),
43
+ files: files.map((f) => ({ path: f.path, mimeType: f.mimeType, bytes: f.bytes, encoding: f.encoding, uri: skillFileUri(skill.name, f.path) })),
44
+ };
45
+ }
29
46
  export const TOOL_PREFIX = "mzizi_";
30
47
  /** Unwrap a registry result into an MCP tool result, keeping the failure readable. */
31
48
  function respond(result, wrapKey) {
@@ -40,6 +57,7 @@ function respond(result, wrapKey) {
40
57
  */
41
58
  const READ = {
42
59
  readOnlyHint: true,
60
+ destructiveHint: false,
43
61
  idempotentHint: true,
44
62
  openWorldHint: false,
45
63
  };
@@ -60,6 +78,9 @@ const READ = {
60
78
  * are always reported, so nothing is hidden and nothing is silently truncated.
61
79
  */
62
80
  const LIST_DEFAULT_LIMIT = 400;
81
+ /** Default and maximum hits per `search` call (the default was the registry's own 50). */
82
+ const SEARCH_DEFAULT_LIMIT = 50;
83
+ const SEARCH_MAX_LIMIT = 200;
63
84
  /**
64
85
  * A number argument that also accepts the string form of itself.
65
86
  *
@@ -180,13 +201,135 @@ function astroSection(doc) {
180
201
  files: doc.files.map((f) => f.encoding === "base64" ? { path: f.path, type: f.type, target: f.target, encoding: f.encoding } : f),
181
202
  };
182
203
  }
183
- /** The React build, second and labelled: the registry's `/v1/ui/{name}` body, unchanged. */
184
- function reactSection(body) {
204
+ /** Lowercase hex SHA-256 of a UTF-8 string. Web Crypto, so it runs in the Worker and the stdio bin. */
205
+ async function sha256Hex(text) {
206
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
207
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
208
+ }
209
+ /**
210
+ * The source with its `//` line and `/* *\/` block comments removed, so an import
211
+ * written in a comment is not read as one. String and template literals are copied
212
+ * through untouched, so a `//` inside `"https://…"` does not start a comment.
213
+ */
214
+ function stripComments(src) {
215
+ let out = "";
216
+ let i = 0;
217
+ while (i < src.length) {
218
+ const c = src[i];
219
+ const next = src[i + 1];
220
+ if (c === '"' || c === "'" || c === "`") {
221
+ let j = i + 1;
222
+ while (j < src.length && src[j] !== c)
223
+ j += src[j] === "\\" ? 2 : 1;
224
+ out += src.slice(i, j + 1);
225
+ i = j + 1;
226
+ }
227
+ else if (c === "/" && next === "/") {
228
+ const end = src.indexOf("\n", i);
229
+ i = end === -1 ? src.length : end;
230
+ }
231
+ else if (c === "/" && next === "*") {
232
+ const end = src.indexOf("*/", i + 2);
233
+ // Keep a space so the tokens either side of the comment stay apart.
234
+ out += " ";
235
+ i = end === -1 ? src.length : end + 2;
236
+ }
237
+ else {
238
+ out += c;
239
+ i += 1;
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+ /**
245
+ * Every `@/…` module specifier a source file imports, re-exports or loads with a
246
+ * dynamic `import("@/…")`, in first-seen order. Comments are ignored.
247
+ */
248
+ function aliasImports(content) {
249
+ const seen = new Set();
250
+ const code = stripComments(content);
251
+ for (const m of code.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*["'`](@\/[^"'`]+)["'`]/g)) {
252
+ if (m[1])
253
+ seen.add(m[1]);
254
+ }
255
+ return [...seen];
256
+ }
257
+ /** The `@/…` specifier a file installed at `target` answers (`lib/icons/index.tsx` → `@/lib/icons`). */
258
+ function aliasOfTarget(target) {
259
+ return "@/" + target.replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
260
+ }
261
+ const UI_PREFIX = "https://api.mzizi.dev/v1/ui/";
262
+ /**
263
+ * The raw file at exactly `ref`: `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`.
264
+ * Null when the registry is not a GitHub repository or the file has no path.
265
+ */
266
+ function pinnedFileUrl(repository, ref, path) {
267
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(repository);
268
+ if (!m || !path)
269
+ return null;
270
+ const encoded = path.split("/").map(encodeURIComponent).join("/");
271
+ return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${ref}/${encoded}`;
272
+ }
273
+ /**
274
+ * What a consumer needs to vendor the React build without calling the API
275
+ * (mzizi-registry#532): the registry commit this answer comes from, each file's
276
+ * install target, SHA-256 and a URL pinned to that commit, each `@/` import with
277
+ * the declared registry dependency that installs it (or null when none does),
278
+ * and, labelled as live, the API item URL, which serves whatever commit the API
279
+ * is pinned to and can differ from `registry.ref`.
280
+ */
281
+ async function vendorSection(body, r) {
282
+ const name = String(body.name ?? "");
283
+ const files = (Array.isArray(body.files) ? body.files : []);
284
+ const deps = (Array.isArray(body.registryDependencies) ? body.registryDependencies : []);
285
+ // The alias each declared dependency's files answer, so an import can be matched to its installer.
286
+ const provides = new Map();
287
+ for (const dep of deps) {
288
+ if (!dep.startsWith(UI_PREFIX))
289
+ continue;
290
+ const depName = dep.slice(UI_PREFIX.length);
291
+ const got = r.getComponent(depName);
292
+ if (!got.ok)
293
+ continue;
294
+ for (const f of (got.data.files ?? [])) {
295
+ if (f.target)
296
+ provides.set(aliasOfTarget(f.target), depName);
297
+ }
298
+ }
299
+ const own = new Set(files.flatMap((f) => (f.target ? [aliasOfTarget(f.target)] : [])));
300
+ const imports = [...new Set(files.flatMap((f) => aliasImports(f.content ?? "")))]
301
+ .filter((spec) => !own.has(spec))
302
+ .map((spec) => ({ alias: spec, providedBy: provides.get(spec) ?? null }));
303
+ return {
304
+ registry: { repository: r.pin.repository, ref: r.pin.ref },
305
+ files: await Promise.all(files.map(async (f) => ({
306
+ path: f.path ?? null,
307
+ target: f.target ?? null,
308
+ url: pinnedFileUrl(r.pin.repository, r.pin.ref, f.path),
309
+ sha256: typeof f.content === "string" ? await sha256Hex(f.content) : null,
310
+ }))),
311
+ imports,
312
+ live: {
313
+ url: `${UI_PREFIX}${encodeURIComponent(name)}`,
314
+ note: "Live, not pinned: the API serves the registry commit it is deployed at, which can differ from registry.ref, " +
315
+ "so its files may not match these sha256 values. Use files[].url for the pinned source.",
316
+ },
317
+ note: "Components have no version numbers of their own: pin a vendored copy to registry.ref and check each file's sha256. " +
318
+ "Each sha256 is of the registry source at path@registry.ref (what files[].url serves). A file written by `shadcn add` " +
319
+ "can differ from it: shadcn strips \"use client\" when the project sets rsc: false, and rewrites @/ aliases to the " +
320
+ "project's own, so hash the registry source, not the installed file. " +
321
+ "An import whose providedBy is null is not installed by any declared registry dependency. @/lib/utils is provided " +
322
+ "by the registry's utils item, or by shadcn init in a project that ran it.",
323
+ };
324
+ }
325
+ /** The React build, second and labelled: the registry's `/v1/ui/{name}` body, plus `vendor` install metadata. */
326
+ async function reactSection(body, r) {
185
327
  const name = String(body.name ?? "");
186
328
  return {
187
329
  build: "react",
188
330
  status: "deprioritised",
189
331
  install: `npx shadcn@latest add https://api.mzizi.dev/v1/ui/${encodeURIComponent(name)}`,
332
+ vendor: await vendorSection(body, r),
190
333
  ...body,
191
334
  };
192
335
  }
@@ -202,24 +345,68 @@ export function registerMziziTools(server, opts) {
202
345
  // 1 ─ search
203
346
  server.registerTool(note("search"), {
204
347
  title: "Search Mzizi",
205
- description: "Search the registry by words: components, conventions, AI instruction sets and releases. Start here when you do not know the exact name of what you want. A component hit with `rustCrate` has a Mzizi Roots (Rust) implementation, which is the lead; at an equal score it ranks first. Documentation pages are searched by the docs_* tools.",
206
- inputSchema: {
207
- query: z.string().min(1).describe("Free-text search term."),
208
- },
348
+ description: "Search the registry by words: components, conventions, AI instruction sets and releases. Start here when you do not know the exact name of what you want; use mzizi_get_component once you have a component's name. A component hit with `rustCrate` has a Mzizi Roots (Rust) implementation, which is the lead; at an equal score it ranks first. Documentation pages are searched by the docs_* tools. " +
349
+ `Paged: up to ${SEARCH_DEFAULT_LIMIT} hits per call and at most ${CHARACTER_LIMIT} characters; meta.has_more and meta.next_offset say how to get the rest.`,
350
+ inputSchema: z.object({
351
+ query: z.string().min(1).max(500).describe("Free-text search term, e.g. 'button' or 'date picker'. Every word must match."),
352
+ limit: NUM.int().positive().optional().describe(`Hits per call (default ${SEARCH_DEFAULT_LIMIT}, at most ${SEARCH_MAX_LIMIT}).`),
353
+ offset: NUM.int().min(0).optional().describe("Hits to skip, for paging. Use meta.next_offset from the previous call."),
354
+ }),
355
+ outputSchema: z.object({
356
+ results: z.object({
357
+ data: z.array(z.looseObject({
358
+ kind: z.string(),
359
+ name: z.string(),
360
+ title: z.string().optional(),
361
+ description: z.string().optional(),
362
+ rustCrate: z.string().optional(),
363
+ astroFile: z.string().optional(),
364
+ })),
365
+ meta: z.looseObject({
366
+ total: z.number(),
367
+ count: z.number(),
368
+ offset: z.number(),
369
+ has_more: z.boolean(),
370
+ next_offset: z.number().optional(),
371
+ }),
372
+ }),
373
+ }),
209
374
  annotations: READ,
210
- }, async ({ query }) => respond((await reg()).search(query), "results"));
375
+ }, async ({ query, limit, offset }) => {
376
+ const res = (await reg()).search(query, Number.MAX_SAFE_INTEGER);
377
+ const all = res.data.data;
378
+ const skip = offset ?? 0;
379
+ const take = Math.min(limit ?? SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
380
+ const render = (page) => {
381
+ const next = skip + page.length;
382
+ const hasMore = next < all.length;
383
+ return {
384
+ data: page,
385
+ meta: {
386
+ ...res.data.meta,
387
+ total: all.length,
388
+ count: page.length,
389
+ offset: skip,
390
+ has_more: hasMore,
391
+ ...(hasMore ? { next_offset: next } : {}),
392
+ },
393
+ };
394
+ };
395
+ const { result } = fitToLimit(all.slice(skip, skip + take), render);
396
+ return jsonContent(result, "results");
397
+ });
211
398
  // 2 ─ get_component — one read, everything, Rust first
212
399
  server.registerTool(note("get_component"), {
213
400
  title: "Get a component",
214
- description: "Fetch one component in full, Rust first. When the component has a Mzizi Roots (Rust, Dioxus) implementation, `rust` comes first: its crate, the `cargo add` line, its CONTRACT clause block and the `.rs` source inline. `astro` follows when the component has a pure Astro implementation (mzizi-registry#397): its `mzizi add --target astro` line, the files it installs into src/components/mzizi/ and their dependencies; null otherwise. `react` follows: the React build (deprioritised; it keeps working, new work goes to Rust), shadcn-shaped with files[] inline and its `npx shadcn add` line. With no Rust, `rust` is null and `lead` is \"react\". " +
401
+ description: "Fetch one component in full, Rust first. When the component has a Mzizi Roots (Rust) implementation, `rust` comes first (Roots' crates are built on Dioxus 0.7 today and are moving to Mzizi's own renderer): its crate, the `cargo add` line, its CONTRACT clause block and the `.rs` source inline. `astro` follows when the component has a pure Astro implementation (mzizi-registry#397): its `mzizi add --target astro` line, the files it installs into src/components/mzizi/ and their dependencies; null otherwise. `react` follows: the React build (deprioritised; it keeps working, new work goes to Rust), shadcn-shaped with files[] inline, its `npx shadcn add` line, and `vendor`: the registry commit, each file's install target, sha256 and a raw URL pinned to that commit, each `@/` import with the dependency that installs it, and the live API item URL (labelled live: it can serve a different commit). Each sha256 is of the registry source at path@ref; a file `shadcn add` writes can differ (it strips \"use client\" under rsc: false and rewrites aliases). With no Rust, `rust` is null and `lead` is \"react\". " +
215
402
  "Add docs (use cases, variants, a11y: the documented contract) and version history (from the changelog) with `include`. Former nyuchi-* names resolve to their mzizi-* successors.",
216
- inputSchema: {
217
- name: z.string().min(1).describe("Component name, e.g. 'button'."),
403
+ inputSchema: z.object({
404
+ name: z.string().max(200).min(1).describe("Component name, e.g. 'button'."),
218
405
  include: z
219
406
  .array(z.enum(["docs", "versions"]))
220
407
  .optional()
221
408
  .describe("Extra sections to fetch. Omit for the implementations only."),
222
- },
409
+ }),
223
410
  annotations: READ,
224
411
  }, async ({ name, include }) => {
225
412
  const r = await reg();
@@ -237,10 +424,12 @@ export function registerMziziTools(server, opts) {
237
424
  note: rust.ok
238
425
  ? `Rust first: "${current}" has a Mzizi Roots implementation in the ${rust.data.crate.name} crate — use it. ` +
239
426
  "The React build below is deprioritised: it keeps working, but new work goes to Rust."
240
- : `No Mzizi Roots (Rust) implementation yet. ${rust.message}`,
427
+ : rust.message,
241
428
  rust: rust.ok ? rustSection(rust.data, r.pin.ref) : null,
242
429
  astro: astro.ok ? astroSection(astro.data) : null,
243
- react: react.ok ? reactSection(react.data) : { build: "react", error: react.message },
430
+ react: react.ok
431
+ ? await reactSection(react.data, r)
432
+ : { build: "react", error: react.message },
244
433
  };
245
434
  // A failure in an extra section must not discard the component the caller
246
435
  // actually asked for -- it is reported alongside it.
@@ -259,8 +448,8 @@ export function registerMziziTools(server, opts) {
259
448
  "Returns a lean row per component (name, type, node, owner, collection, and rustCrate " +
260
449
  "when it has a Mzizi Roots (Rust) implementation, which is the lead) — use " +
261
450
  "get_component for one component in full, or search when you only have words. " +
262
- `Paged: up to ${LIST_DEFAULT_LIMIT} rows per call, with total and hasMore reported.`,
263
- inputSchema: {
451
+ `Paged: up to ${LIST_DEFAULT_LIMIT} rows and at most ${CHARACTER_LIMIT} characters per call; total, has_more and next_offset say how to get the rest.`,
452
+ inputSchema: z.object({
264
453
  // No .max(): the node set is uncapped, and a Zod bound would reject a filter
265
454
  // before it reached the data, turning a new node into a schema error.
266
455
  node: NUM.int()
@@ -268,15 +457,15 @@ export function registerMziziTools(server, opts) {
268
457
  .optional()
269
458
  .describe("Filter by node number (uncapped)."),
270
459
  owner: z
271
- .string()
460
+ .string().max(100)
272
461
  .optional()
273
462
  .describe("Filter by owner: mzizi | nyuchi | bundu | framework."),
274
463
  collection: z
275
- .string()
464
+ .string().max(100)
276
465
  .optional()
277
466
  .describe("Filter by collection, e.g. primitives | brand | pages | resilience."),
278
467
  type: z
279
- .string()
468
+ .string().max(100)
280
469
  .optional()
281
470
  .describe("Filter by registry type, e.g. registry:ui | registry:lib | registry:block."),
282
471
  rust: BOOL.optional().describe("true: only components with a Mzizi Roots (Rust) implementation; false: only those without."),
@@ -284,12 +473,30 @@ export function registerMziziTools(server, opts) {
284
473
  limit: NUM.int()
285
474
  .positive()
286
475
  .optional()
287
- .describe(`Rows per call (default ${LIST_DEFAULT_LIMIT}).`),
476
+ .describe(`Rows per call (default ${LIST_DEFAULT_LIMIT}). A page also stops at ${CHARACTER_LIMIT} characters.`),
288
477
  offset: NUM.int()
289
478
  .min(0)
290
479
  .optional()
291
480
  .describe("Rows to skip, for paging."),
292
- },
481
+ }),
482
+ outputSchema: z.object({
483
+ count: z.number(),
484
+ total: z.number(),
485
+ offset: z.number(),
486
+ has_more: z.boolean(),
487
+ next_offset: z.number().optional(),
488
+ hasMore: z.boolean(),
489
+ nextOffset: z.number().optional(),
490
+ hint: z.string().optional(),
491
+ registryTotal: z.number().optional(),
492
+ withRust: z.number(),
493
+ withAstro: z.number(),
494
+ items: z.array(z.looseObject({
495
+ name: z.unknown(),
496
+ rustCrate: z.string().optional(),
497
+ astroFile: z.string().optional(),
498
+ })),
499
+ }),
293
500
  annotations: READ,
294
501
  }, async ({ node, owner, collection, type, rust, astro, limit, offset }) => {
295
502
  const r = await reg();
@@ -329,40 +536,47 @@ export function registerMziziTools(server, opts) {
329
536
  ...(astroFile ? { astroFile } : {}),
330
537
  };
331
538
  });
332
- const returned = skip + items.length;
333
- return jsonContent({
334
- count: items.length,
335
- total,
336
- offset: skip,
337
- hasMore: returned < total,
338
- ...(returned < total
339
- ? {
340
- nextOffset: returned,
341
- hint: `Call again with offset=${returned}, or narrow with node/owner/collection.`,
342
- }
343
- : {}),
344
- // Present so a caller can distinguish "your filter matched nothing" from "the
345
- // registry is empty" — the two look identical in an empty items array, and the
346
- // first is a query to rewrite while the second is an outage.
347
- ...(registryTotal !== undefined ? { registryTotal } : {}),
348
- // How many of the `total` matched rows have a Mzizi Roots (Rust)
349
- // implementation. Filter with rust=true to list only those.
350
- withRust: payload.meta?.withRust ?? 0,
351
- // How many have an Astro implementation. Filter with astro=true to list only those.
352
- withAstro: payload.meta?.withAstro ?? 0,
353
- items,
354
- });
539
+ const render = (page) => {
540
+ const returned = skip + page.length;
541
+ const more = returned < total;
542
+ return {
543
+ count: page.length,
544
+ total,
545
+ offset: skip,
546
+ has_more: more,
547
+ ...(more ? { next_offset: returned } : {}),
548
+ // camelCase kept for clients written against 0.13 and earlier.
549
+ hasMore: more,
550
+ ...(more
551
+ ? {
552
+ nextOffset: returned,
553
+ hint: `Call again with offset=${returned}, or narrow with node/owner/collection.`,
554
+ }
555
+ : {}),
556
+ // Present so a caller can distinguish "your filter matched nothing" from "the
557
+ // registry is empty" — the two look identical in an empty items array, and the
558
+ // first is a query to rewrite while the second is an outage.
559
+ ...(registryTotal !== undefined ? { registryTotal } : {}),
560
+ // How many of the `total` matched rows have a Mzizi Roots (Rust)
561
+ // implementation. Filter with rust=true to list only those.
562
+ withRust: payload.meta?.withRust ?? 0,
563
+ // How many have an Astro implementation. Filter with astro=true to list only those.
564
+ withAstro: payload.meta?.withAstro ?? 0,
565
+ items: page,
566
+ };
567
+ };
568
+ return jsonContent(fitToLimit(items, render).result);
355
569
  });
356
570
  // 4 ─ get_tokens
357
571
  server.registerTool(note("get_tokens"), {
358
572
  title: "Get design tokens",
359
- description: "The Mzizi brand system: 21 colour families (7 minerals, 7 heritage, 7 experimental), semantic colours, typography, spacing, radii and the ecosystem brands. Ask for `colors` to get every colour family in one call.",
360
- inputSchema: {
573
+ description: "The Mzizi brand system: 21 colour families (7 minerals, 7 heritage, 7 experimental), semantic colours, typography, spacing, radii and the ecosystem brands, and the Bundu floor: the minimums nothing can go under (17px body, 14px captions, 49px hit region, 7:1 body contrast, and " + SPACING_PHRASE + "). Ask for `floors` for the floor in one call (the spacing floor is also under `accessibility`), and for `colors` to get every colour family in one call. For text, use each family's `textLight` / `textDark` on the base surface, and `onContainerLight` / `onContainerDark` on its container; never use `hex`, `lightHex` or `darkHex` as a text colour (they are accents).",
574
+ inputSchema: z.object({
361
575
  family: z
362
- .string()
576
+ .string().max(100)
363
577
  .optional()
364
- .describe("One of: colors (all 21 colour families at once), minerals, heritage, experimental, semantic, backgrounds, typography, spacing, radii, ecosystem, componentSpecs, accessibility, voiceAndTone, philosophy. Common aliases resolve (status/semanticColors, radius, type/fonts, exp). Omit for everything."),
365
- },
578
+ .describe("One of: colors (all 21 colour families at once), floors (the Bundu floor: sizes, contrast and the spacing floor, with how it is enforced), minerals, heritage, experimental, semantic, backgrounds, typography, spacing (the scale, plus the spacing floor), radii, ecosystem, componentSpecs, accessibility, voiceAndTone, philosophy. Common aliases resolve (status/semanticColors, radius, type/fonts, exp, floor/bundu-floor/spacing-floor). Omit for everything."),
579
+ }),
366
580
  annotations: READ,
367
581
  }, async ({ family }) => {
368
582
  const res = (await reg()).getBrand();
@@ -441,6 +655,10 @@ export function registerMziziTools(server, opts) {
441
655
  out[k] = brand[k];
442
656
  return jsonContent(out);
443
657
  }
658
+ // The Bundu floor in one call, with the numbers the pinned registry states.
659
+ if (["floor", "floors", "bundufloor", "bundufloors", "spacingfloor", "minimums"].includes(asked)) {
660
+ return jsonContent({ floors: describeFloors(brand) });
661
+ }
444
662
  const resolved = ALIASES[asked];
445
663
  const key = resolved && resolved in brand
446
664
  ? resolved
@@ -449,18 +667,25 @@ export function registerMziziTools(server, opts) {
449
667
  return toolError(`No token family matching "${family}". Available: ${families.join(", ")}. ` +
450
668
  `Use "colors" for every colour family at once.`);
451
669
  }
670
+ // Asking for the spacing scale also returns the floor under it, so a scale step
671
+ // below the floor is not mistaken for a licence to use it as padding or a gap.
672
+ if (key === "spacing") {
673
+ const spacingFloor = spacingFloorFrom(brand);
674
+ if (spacingFloor)
675
+ return jsonContent({ spacing: brand.spacing, spacingFloor });
676
+ }
452
677
  return jsonContent({ [key]: brand[key] });
453
678
  });
454
679
  // 5 ─ get_architecture
455
680
  server.registerTool(note("get_architecture"), {
456
681
  title: "Get the architecture",
457
682
  description: "The Mzizi DNA double helix — nodes, rungs and strands — with live counts. Pass `node` for one node's covenant, stakeholder and implementation rules.",
458
- inputSchema: {
683
+ inputSchema: z.object({
459
684
  node: NUM.int()
460
685
  .positive()
461
686
  .optional()
462
687
  .describe("One node's detail (uncapped)."),
463
- },
688
+ }),
464
689
  annotations: READ,
465
690
  }, async ({ node }) => {
466
691
  const r = await reg();
@@ -469,19 +694,21 @@ export function registerMziziTools(server, opts) {
469
694
  // 6 ─ get_doctrine
470
695
  server.registerTool(note("get_doctrine"), {
471
696
  title: "Get doctrine",
472
- description: "How to build here: Ubuntu pillars and principles, mzizi conventions, and the AI instruction sets.",
473
- inputSchema: {
697
+ description: "How to build here: Ubuntu pillars and principles, mzizi conventions, the AI instruction sets, and the Bundu floor (topic `bundu-floor`): the minimums nothing can go under, including " + SPACING_PHRASE + ", how they are enforced and what to do.",
698
+ inputSchema: z.object({
474
699
  topic: z
475
- .enum(["ubuntu-pillars", "ubuntu-principles", "ai-instructions", "conventions"])
700
+ .enum(["ubuntu-pillars", "ubuntu-principles", "ai-instructions", "conventions", "bundu-floor"])
476
701
  .describe("Which doctrine to read."),
477
702
  name: z
478
- .string()
703
+ .string().max(200)
479
704
  .optional()
480
705
  .describe("For ai-instructions, the name or target to fetch; for conventions, the convention's name. Omit to list."),
481
- },
706
+ }),
482
707
  annotations: READ,
483
708
  }, async ({ topic, name }) => {
484
709
  const r = await reg();
710
+ if (topic === "bundu-floor")
711
+ return jsonContent({ floors: describeFloors(r.getBrand().data) });
485
712
  if (topic === "ubuntu-pillars")
486
713
  return respond(r.getUbuntuPillars(), "pillars");
487
714
  if (topic === "ubuntu-principles")
@@ -493,14 +720,18 @@ export function registerMziziTools(server, opts) {
493
720
  // 7 ─ get_skills — served from the bundle this Worker ships with
494
721
  server.registerTool(note("get_skills"), {
495
722
  title: "Get agent skills",
496
- description: "List the published Mzizi agent skills, or fetch one in full. Skills are what an agent loads first. Each skill has a category: `dev` (how every agent works in every repo: digital-hygiene and progress-report — load these before any dev work), `design`, `language` or `build`. Pass `category` to get every skill in it in full (e.g. category=dev), or `name` for one skill. Served from the skill files bundled into this server at build time, so they are exactly the text in mzizi-dev/agent-tools at the deployed commit.",
497
- inputSchema: {
498
- name: z.string().optional().describe("Skill name. Omit to list all."),
723
+ description: "List the published Mzizi agent skills, or fetch one in full. Skills are what an agent loads first. Each skill has a category: `dev` (how every agent works in every repo: digital-hygiene and progress-report — load these before any dev work), `design`, `language` or `build`. Pass `category` to get every skill in it in full (e.g. category=dev), or `name` for one skill. A skill's answer lists the files it ships beside its SKILL.md (scripts such as hygiene.sh, token stylesheets); pass `name` and `file` to get one file's content. Each skill is also an MCP resource (mzizi://skills/<name>, files at mzizi://skills/<name>/<path>) and a prompt (skill-<name>). Served from the skill files bundled into this server at build time, so they are exactly the text in mzizi-dev/agent-tools at the deployed commit.",
724
+ inputSchema: z.object({
725
+ name: z.string().max(200).optional().describe("Skill name. Omit to list all."),
726
+ file: z
727
+ .string().max(500)
728
+ .optional()
729
+ .describe("With `name`: a file the skill ships, by the path its `files` list gives (e.g. hygiene.sh). Returns that file's content."),
499
730
  category: z
500
731
  .enum(SKILL_CATEGORIES)
501
732
  .optional()
502
733
  .describe("Return every skill in this category in full: dev (digital-hygiene, progress-report — for every agent in every repo), design, language or build."),
503
- },
734
+ }),
504
735
  annotations: READ,
505
736
  },
506
737
  // No network call, and that is the point. This used to read
@@ -509,7 +740,9 @@ export function registerMziziTools(server, opts) {
509
740
  // instructing agents to write into a database column that had been cleared.
510
741
  // The Worker now answers from the bundle it was built with: one hop, and a
511
742
  // merge is the only way the answer changes.
512
- async ({ name, category }) => {
743
+ async ({ name, category, file }) => {
744
+ if (file && !name)
745
+ return toolError("`file` needs `name`: the skill whose file it is.");
513
746
  if (name) {
514
747
  const skill = BUNDLED_SKILLS.find((s) => s.name === name);
515
748
  if (!skill) {
@@ -518,7 +751,14 @@ export function registerMziziTools(server, opts) {
518
751
  if (category && skill.category !== category) {
519
752
  return toolError(`Skill "${name}" is in category "${skill.category}", not "${category}".`);
520
753
  }
521
- return jsonContent({ version: SKILLS_VERSION, ...skill }, "skills");
754
+ if (file) {
755
+ const f = skill.files.find((x) => x.path === file);
756
+ if (!f) {
757
+ return toolError(`Skill "${name}" ships no file "${file}". Its files: ${skill.files.map((x) => x.path).join(", ") || "none"}.`);
758
+ }
759
+ return jsonContent({ version: SKILLS_VERSION, skill: name, path: f.path, mimeType: f.mimeType, bytes: f.bytes, uri: skillFileUri(name, f.path), encoding: f.encoding, content: f.content }, "skills");
760
+ }
761
+ return jsonContent({ version: SKILLS_VERSION, ...skillView(skill) }, "skills");
522
762
  }
523
763
  if (category) {
524
764
  // A category is a working set an agent loads together (category=dev is
@@ -526,7 +766,7 @@ export function registerMziziTools(server, opts) {
526
766
  return jsonContent({
527
767
  version: SKILLS_VERSION,
528
768
  category,
529
- skills: BUNDLED_SKILLS.filter((s) => s.category === category),
769
+ skills: BUNDLED_SKILLS.filter((s) => s.category === category).map(skillView),
530
770
  }, "skills");
531
771
  }
532
772
  // The list omits bodies — seven full skills is ~170 kB, and a client
@@ -534,66 +774,125 @@ export function registerMziziTools(server, opts) {
534
774
  return jsonContent({
535
775
  version: SKILLS_VERSION,
536
776
  categories: SKILL_CATEGORIES,
537
- skills: BUNDLED_SKILLS.map(({ name: n, category: c, description, source }) => ({
777
+ skills: BUNDLED_SKILLS.map(({ name: n, category: c, description, source, files }) => ({
538
778
  name: n,
539
779
  category: c,
540
780
  description,
541
781
  source,
782
+ uri: skillUri(n),
783
+ files: files.map((f) => f.path),
542
784
  })),
543
785
  }, "skills");
544
786
  });
545
787
  // 8 ─ check_accessibility
546
788
  server.registerTool(note("check_accessibility"), {
547
789
  title: "Check accessibility",
548
- description: "Contrast and colour-vision checks against the Mzizi APCA/AAA floor. Computed locally — no network, no upstream dependency.",
549
- inputSchema: {
550
- foreground: z.string().describe("Foreground colour as #RRGGBB."),
551
- background: z.string().describe("Background colour as #RRGGBB."),
552
- },
790
+ description: "Check a design against the Mzizi floors. Colours: the WCAG 2.x contrast ratio of a foreground on a background (#RRGGBB), with the AA-large, AA and AAA verdicts and whether it meets the Mzizi floor of 7:1. Spacing: pass any of `insetPx` (text to container edge), `controlGapPx`, `labelGapPx`, `itemGapPx` (gaps) and `leading` (body line height) and each is compared with the spacing floor the pinned registry states (nothing may go under it): " + SPACING_PHRASE + ". Give colours, spacing values, or both. Computed locally, no network. It does not compute APCA Lc or simulate colour-vision deficiency; read the Mzizi accessibility doctrine (mzizi_get_tokens family=accessibility) for those rules. To check a project's source rather than values, run `mzizi check` (@nyuchi/mzizi-cli).",
791
+ inputSchema: z.object({
792
+ foreground: z.string().max(16).optional().describe("Foreground colour as #RRGGBB, e.g. #1B1A17. Give with background."),
793
+ background: z.string().max(16).optional().describe("Background colour as #RRGGBB, e.g. #FFFFFF. Give with foreground."),
794
+ insetPx: z.number().min(0).max(1000).optional().describe("Padding between text and its container edge, in px."),
795
+ controlGapPx: z.number().min(0).max(1000).optional().describe("Gap between adjacent interactive controls, visible edge to visible edge, in px."),
796
+ labelGapPx: z.number().min(0).max(1000).optional().describe("Gap between a label and its field, in px."),
797
+ itemGapPx: z.number().min(0).max(1000).optional().describe("Gap between sibling items in a list, grid or toolbar, in px."),
798
+ leading: z.number().min(0).max(10).optional().describe("Body line height as a unitless ratio, e.g. 1.5."),
799
+ }),
800
+ outputSchema: z.object({
801
+ foreground: z.string().optional(),
802
+ background: z.string().optional(),
803
+ contrastRatio: z.number().optional(),
804
+ passes: z.object({ aaLarge: z.boolean(), aa: z.boolean(), aaa: z.boolean() }).optional(),
805
+ meetsMziziFloor: z.boolean().optional(),
806
+ spacingUnavailable: z.string().optional(),
807
+ spacing: z
808
+ .object({
809
+ floor: z.object({
810
+ insetPx: z.number(),
811
+ controlGapPx: z.number(),
812
+ labelGapPx: z.number(),
813
+ itemGapPx: z.number(),
814
+ leadingBody: z.number(),
815
+ }),
816
+ checks: z.array(z.object({
817
+ name: z.string(),
818
+ value: z.number(),
819
+ minimum: z.number(),
820
+ unit: z.enum(["px", "ratio"]),
821
+ passes: z.boolean(),
822
+ })),
823
+ meetsFloor: z.boolean(),
824
+ })
825
+ .optional(),
826
+ }),
553
827
  annotations: READ,
554
- }, async ({ foreground, background }) => {
555
- const parse = (hex) => {
556
- const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
557
- if (!m?.[1])
558
- return null;
559
- const n = Number.parseInt(m[1], 16);
560
- return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
561
- };
562
- const fg = parse(foreground);
563
- const bg = parse(background);
564
- if (!fg || !bg)
565
- return toolError("Both colours must be 6-digit hex, e.g. #1B1A17.");
566
- // WCAG relative luminance.
567
- const lum = ([r, g, b]) => {
568
- const [cr, cg, cb] = [r, g, b].map((v) => {
569
- const s = v / 255;
570
- return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
828
+ }, async ({ foreground, background, insetPx, controlGapPx, labelGapPx, itemGapPx, leading }) => {
829
+ const hasSpacing = [insetPx, controlGapPx, labelGapPx, itemGapPx, leading].some((v) => v !== undefined);
830
+ const hasColours = foreground !== undefined || background !== undefined;
831
+ if (!hasSpacing && !hasColours) {
832
+ return toolError("Give a foreground and background colour (#RRGGBB), spacing values (insetPx, controlGapPx, labelGapPx, itemGapPx, leading), or both.");
833
+ }
834
+ if (hasColours && (foreground === undefined || background === undefined)) {
835
+ return toolError("Give both foreground and background, e.g. #1B1A17 on #FFFFFF.");
836
+ }
837
+ const out = {};
838
+ if (foreground !== undefined && background !== undefined) {
839
+ const parse = (hex) => {
840
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
841
+ if (!m?.[1])
842
+ return null;
843
+ const n = Number.parseInt(m[1], 16);
844
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
845
+ };
846
+ const fg = parse(foreground);
847
+ const bg = parse(background);
848
+ if (!fg || !bg)
849
+ return toolError("Both colours must be 6-digit hex, e.g. #1B1A17.");
850
+ // WCAG relative luminance.
851
+ const lum = ([r, g, b]) => {
852
+ const [cr, cg, cb] = [r, g, b].map((v) => {
853
+ const s = v / 255;
854
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
855
+ });
856
+ return 0.2126 * cr + 0.7152 * cg + 0.0722 * cb;
857
+ };
858
+ const l1 = lum(fg);
859
+ const l2 = lum(bg);
860
+ const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
861
+ const rounded = Math.round(ratio * 100) / 100;
862
+ Object.assign(out, {
863
+ foreground,
864
+ background,
865
+ contrastRatio: rounded,
866
+ passes: {
867
+ aaLarge: rounded >= 3,
868
+ aa: rounded >= 4.5,
869
+ aaa: rounded >= 7,
870
+ },
871
+ // Mzizi's floor is AAA (7:1), stricter than WCAG AA -- say so rather than
872
+ // letting a 4.5 look like a pass here.
873
+ meetsMziziFloor: rounded >= 7,
571
874
  });
572
- return 0.2126 * cr + 0.7152 * cg + 0.0722 * cb;
573
- };
574
- const l1 = lum(fg);
575
- const l2 = lum(bg);
576
- const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
577
- const rounded = Math.round(ratio * 100) / 100;
578
- return jsonContent({
579
- foreground,
580
- background,
581
- contrastRatio: rounded,
582
- passes: {
583
- aaLarge: rounded >= 3,
584
- aa: rounded >= 4.5,
585
- aaa: rounded >= 7,
586
- },
587
- // Mzizi's floor is AAA (7:1), stricter than WCAG AA -- say so rather than
588
- // letting a 4.5 look like a pass here.
589
- meetsMziziFloor: rounded >= 7,
590
- });
875
+ }
876
+ if (hasSpacing) {
877
+ const floor = spacingFloorFrom((await reg()).getBrand().data);
878
+ if (floor) {
879
+ out.spacing = checkSpacing(floor, { insetPx, controlGapPx, labelGapPx, itemGapPx, leading });
880
+ }
881
+ else if (!hasColours) {
882
+ return toolError(SPACING_FLOOR_MISSING);
883
+ }
884
+ else {
885
+ // The colour result below is computed locally and valid: keep it, and say why spacing is absent.
886
+ out.spacingUnavailable = SPACING_FLOOR_MISSING;
887
+ }
888
+ }
889
+ return jsonContent(out);
591
890
  });
592
891
  // 9 ─ report_issue — the write sink, backed by the per-user issue desk
593
892
  server.registerTool(note("report_issue"), {
594
893
  title: "Report an issue",
595
894
  description: "Report a problem with a component or the registry. It goes to your own issue desk — a Durable Object holding your reports — which reproduces it via fundi, drafts an issue body including what the reproduction actually returned, and logs it to fundi. fundi files the GitHub issue with its proposed fix in it and tracks that issue afterwards, so action 'get' shows where yours got to: filed, picked up, closed. Use 'list' to see your earlier reports and 'retry' to re-attempt one that drafted but could not be logged. Your history is included in each new issue, because a repeated complaint from one reporter is often one bug. Needs sign-in: the desk belongs to your Mzizi console user.",
596
- inputSchema: {
895
+ inputSchema: z.object({
597
896
  action: z
598
897
  .enum(["file", "list", "get", "retry"])
599
898
  .default("file")
@@ -603,24 +902,24 @@ export function registerMziziTools(server, opts) {
603
902
  .optional()
604
903
  .describe("Report kind. Required for action 'file'."),
605
904
  component: z
606
- .string()
905
+ .string().max(200)
607
906
  .optional()
608
907
  .describe("Component the report concerns."),
609
908
  message: z
610
- .string()
909
+ .string().max(20000)
611
910
  .optional()
612
911
  .describe("What happened. Required for action 'file'."),
613
912
  severity: z.enum(["low", "medium", "high", "critical"]).optional(),
614
913
  skill: z
615
- .string()
914
+ .string().max(100)
616
915
  .optional()
617
916
  .describe("fundi skill to run for the reproduction. fundi never infers a skill from text, so omit this to let the report kind choose one."),
618
917
  reportId: z
619
- .string()
918
+ .string().max(200)
620
919
  .optional()
621
920
  .describe("Report id. Required for actions 'get' and 'retry'."),
622
921
  limit: z.number().int().min(1).max(100).optional(),
623
- },
922
+ }),
624
923
  annotations: WRITE,
625
924
  }, async (args) => {
626
925
  const desk = opts.desk;
@@ -673,21 +972,21 @@ export function registerMziziTools(server, opts) {
673
972
  server.registerTool(note("fundi"), {
674
973
  title: "fundi",
675
974
  description: "Delegate a task to the fundi agent, or check on one. Submit-and-poll: `submit` returns a task id immediately and never waits for the run. Needs sign-in: fundi runs as your Mzizi console user.",
676
- inputSchema: {
975
+ inputSchema: z.object({
677
976
  action: z.enum(["submit", "task_status", "cancel"]),
678
977
  skill: z
679
978
  .enum(["security-scan", "chaos-run", "a11y-audit", "heal-issue"])
680
979
  .optional()
681
980
  .describe("Required for submit."),
682
981
  instruction: z
683
- .string()
982
+ .string().max(20000)
684
983
  .optional()
685
984
  .describe("What fundi should do. Required for submit."),
686
985
  taskId: z
687
- .string()
986
+ .string().max(200)
688
987
  .optional()
689
988
  .describe("Required for task_status and cancel."),
690
- },
989
+ }),
691
990
  annotations: { ...WRITE, destructiveHint: true },
692
991
  }, async ({ action, skill, instruction, taskId }) => {
693
992
  if (!f)