@nyuchi/mzizi-mcp 0.13.0 → 0.14.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 (45) hide show
  1. package/README.md +112 -7
  2. package/dist/docs-federation.d.ts +1 -1
  3. package/dist/docs-federation.d.ts.map +1 -1
  4. package/dist/docs-federation.js +1 -0
  5. package/dist/docs-federation.js.map +1 -1
  6. package/dist/fundi-client.d.ts +6 -0
  7. package/dist/fundi-client.d.ts.map +1 -1
  8. package/dist/fundi-client.js +14 -3
  9. package/dist/fundi-client.js.map +1 -1
  10. package/dist/generated/registry-pin.d.ts +1 -1
  11. package/dist/generated/registry-pin.js +1 -1
  12. package/dist/generated/registry.d.ts +2 -2
  13. package/dist/generated/registry.d.ts.map +1 -1
  14. package/dist/generated/registry.js +254 -198
  15. package/dist/generated/registry.js.map +1 -1
  16. package/dist/generated/skills.d.ts +11 -1
  17. package/dist/generated/skills.d.ts.map +1 -1
  18. package/dist/generated/skills.js +213 -18
  19. package/dist/generated/skills.js.map +1 -1
  20. package/dist/http.d.ts +22 -2
  21. package/dist/http.d.ts.map +1 -1
  22. package/dist/http.js +116 -19
  23. package/dist/http.js.map +1 -1
  24. package/dist/limits.d.ts +32 -0
  25. package/dist/limits.d.ts.map +1 -0
  26. package/dist/limits.js +44 -0
  27. package/dist/limits.js.map +1 -0
  28. package/dist/registry.d.ts +1 -1
  29. package/dist/registry.d.ts.map +1 -1
  30. package/dist/registry.js +22 -1
  31. package/dist/registry.js.map +1 -1
  32. package/dist/server.d.ts +37 -2
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +96 -3
  35. package/dist/server.js.map +1 -1
  36. package/dist/stdio.js +5 -4
  37. package/dist/stdio.js.map +1 -1
  38. package/dist/tools.d.ts +21 -1
  39. package/dist/tools.d.ts.map +1 -1
  40. package/dist/tools.js +307 -75
  41. package/dist/tools.js.map +1 -1
  42. package/dist/worker.d.ts.map +1 -1
  43. package/dist/worker.js +3 -0
  44. package/dist/worker.js.map +1 -1
  45. package/package.json +18 -17
package/dist/tools.js CHANGED
@@ -25,7 +25,20 @@ 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";
28
29
  import { BUNDLED_SKILLS, SKILL_CATEGORIES, SKILLS_VERSION } from "./generated/skills.js";
30
+ /** The MCP resource URI of a skill's SKILL.md body, and of a file it ships. */
31
+ export const skillUri = (name) => `mzizi://skills/${name}`;
32
+ export const skillFileUri = (name, path) => `mzizi://skills/${name}/${path}`;
33
+ /** A skill as the tool returns it: the body in full, its files as a list (content on request). */
34
+ export function skillView(skill) {
35
+ const { files, ...rest } = skill;
36
+ return {
37
+ ...rest,
38
+ uri: skillUri(skill.name),
39
+ files: files.map((f) => ({ path: f.path, mimeType: f.mimeType, bytes: f.bytes, encoding: f.encoding, uri: skillFileUri(skill.name, f.path) })),
40
+ };
41
+ }
29
42
  export const TOOL_PREFIX = "mzizi_";
30
43
  /** Unwrap a registry result into an MCP tool result, keeping the failure readable. */
31
44
  function respond(result, wrapKey) {
@@ -40,6 +53,7 @@ function respond(result, wrapKey) {
40
53
  */
41
54
  const READ = {
42
55
  readOnlyHint: true,
56
+ destructiveHint: false,
43
57
  idempotentHint: true,
44
58
  openWorldHint: false,
45
59
  };
@@ -60,6 +74,9 @@ const READ = {
60
74
  * are always reported, so nothing is hidden and nothing is silently truncated.
61
75
  */
62
76
  const LIST_DEFAULT_LIMIT = 400;
77
+ /** Default and maximum hits per `search` call (the default was the registry's own 50). */
78
+ const SEARCH_DEFAULT_LIMIT = 50;
79
+ const SEARCH_MAX_LIMIT = 200;
63
80
  /**
64
81
  * A number argument that also accepts the string form of itself.
65
82
  *
@@ -180,13 +197,135 @@ function astroSection(doc) {
180
197
  files: doc.files.map((f) => f.encoding === "base64" ? { path: f.path, type: f.type, target: f.target, encoding: f.encoding } : f),
181
198
  };
182
199
  }
183
- /** The React build, second and labelled: the registry's `/v1/ui/{name}` body, unchanged. */
184
- function reactSection(body) {
200
+ /** Lowercase hex SHA-256 of a UTF-8 string. Web Crypto, so it runs in the Worker and the stdio bin. */
201
+ async function sha256Hex(text) {
202
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
203
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
204
+ }
205
+ /**
206
+ * The source with its `//` line and `/* *\/` block comments removed, so an import
207
+ * written in a comment is not read as one. String and template literals are copied
208
+ * through untouched, so a `//` inside `"https://…"` does not start a comment.
209
+ */
210
+ function stripComments(src) {
211
+ let out = "";
212
+ let i = 0;
213
+ while (i < src.length) {
214
+ const c = src[i];
215
+ const next = src[i + 1];
216
+ if (c === '"' || c === "'" || c === "`") {
217
+ let j = i + 1;
218
+ while (j < src.length && src[j] !== c)
219
+ j += src[j] === "\\" ? 2 : 1;
220
+ out += src.slice(i, j + 1);
221
+ i = j + 1;
222
+ }
223
+ else if (c === "/" && next === "/") {
224
+ const end = src.indexOf("\n", i);
225
+ i = end === -1 ? src.length : end;
226
+ }
227
+ else if (c === "/" && next === "*") {
228
+ const end = src.indexOf("*/", i + 2);
229
+ // Keep a space so the tokens either side of the comment stay apart.
230
+ out += " ";
231
+ i = end === -1 ? src.length : end + 2;
232
+ }
233
+ else {
234
+ out += c;
235
+ i += 1;
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+ /**
241
+ * Every `@/…` module specifier a source file imports, re-exports or loads with a
242
+ * dynamic `import("@/…")`, in first-seen order. Comments are ignored.
243
+ */
244
+ function aliasImports(content) {
245
+ const seen = new Set();
246
+ const code = stripComments(content);
247
+ for (const m of code.matchAll(/(?:\bfrom|\bimport)\s*\(?\s*["'`](@\/[^"'`]+)["'`]/g)) {
248
+ if (m[1])
249
+ seen.add(m[1]);
250
+ }
251
+ return [...seen];
252
+ }
253
+ /** The `@/…` specifier a file installed at `target` answers (`lib/icons/index.tsx` → `@/lib/icons`). */
254
+ function aliasOfTarget(target) {
255
+ return "@/" + target.replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
256
+ }
257
+ const UI_PREFIX = "https://api.mzizi.dev/v1/ui/";
258
+ /**
259
+ * The raw file at exactly `ref`: `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`.
260
+ * Null when the registry is not a GitHub repository or the file has no path.
261
+ */
262
+ function pinnedFileUrl(repository, ref, path) {
263
+ const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(repository);
264
+ if (!m || !path)
265
+ return null;
266
+ const encoded = path.split("/").map(encodeURIComponent).join("/");
267
+ return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${ref}/${encoded}`;
268
+ }
269
+ /**
270
+ * What a consumer needs to vendor the React build without calling the API
271
+ * (mzizi-registry#532): the registry commit this answer comes from, each file's
272
+ * install target, SHA-256 and a URL pinned to that commit, each `@/` import with
273
+ * the declared registry dependency that installs it (or null when none does),
274
+ * and, labelled as live, the API item URL, which serves whatever commit the API
275
+ * is pinned to and can differ from `registry.ref`.
276
+ */
277
+ async function vendorSection(body, r) {
278
+ const name = String(body.name ?? "");
279
+ const files = (Array.isArray(body.files) ? body.files : []);
280
+ const deps = (Array.isArray(body.registryDependencies) ? body.registryDependencies : []);
281
+ // The alias each declared dependency's files answer, so an import can be matched to its installer.
282
+ const provides = new Map();
283
+ for (const dep of deps) {
284
+ if (!dep.startsWith(UI_PREFIX))
285
+ continue;
286
+ const depName = dep.slice(UI_PREFIX.length);
287
+ const got = r.getComponent(depName);
288
+ if (!got.ok)
289
+ continue;
290
+ for (const f of (got.data.files ?? [])) {
291
+ if (f.target)
292
+ provides.set(aliasOfTarget(f.target), depName);
293
+ }
294
+ }
295
+ const own = new Set(files.flatMap((f) => (f.target ? [aliasOfTarget(f.target)] : [])));
296
+ const imports = [...new Set(files.flatMap((f) => aliasImports(f.content ?? "")))]
297
+ .filter((spec) => !own.has(spec))
298
+ .map((spec) => ({ alias: spec, providedBy: provides.get(spec) ?? null }));
299
+ return {
300
+ registry: { repository: r.pin.repository, ref: r.pin.ref },
301
+ files: await Promise.all(files.map(async (f) => ({
302
+ path: f.path ?? null,
303
+ target: f.target ?? null,
304
+ url: pinnedFileUrl(r.pin.repository, r.pin.ref, f.path),
305
+ sha256: typeof f.content === "string" ? await sha256Hex(f.content) : null,
306
+ }))),
307
+ imports,
308
+ live: {
309
+ url: `${UI_PREFIX}${encodeURIComponent(name)}`,
310
+ note: "Live, not pinned: the API serves the registry commit it is deployed at, which can differ from registry.ref, " +
311
+ "so its files may not match these sha256 values. Use files[].url for the pinned source.",
312
+ },
313
+ note: "Components have no version numbers of their own: pin a vendored copy to registry.ref and check each file's sha256. " +
314
+ "Each sha256 is of the registry source at path@registry.ref (what files[].url serves). A file written by `shadcn add` " +
315
+ "can differ from it: shadcn strips \"use client\" when the project sets rsc: false, and rewrites @/ aliases to the " +
316
+ "project's own, so hash the registry source, not the installed file. " +
317
+ "An import whose providedBy is null is not installed by any declared registry dependency. @/lib/utils is provided " +
318
+ "by the registry's utils item, or by shadcn init in a project that ran it.",
319
+ };
320
+ }
321
+ /** The React build, second and labelled: the registry's `/v1/ui/{name}` body, plus `vendor` install metadata. */
322
+ async function reactSection(body, r) {
185
323
  const name = String(body.name ?? "");
186
324
  return {
187
325
  build: "react",
188
326
  status: "deprioritised",
189
327
  install: `npx shadcn@latest add https://api.mzizi.dev/v1/ui/${encodeURIComponent(name)}`,
328
+ vendor: await vendorSection(body, r),
190
329
  ...body,
191
330
  };
192
331
  }
@@ -202,24 +341,68 @@ export function registerMziziTools(server, opts) {
202
341
  // 1 ─ search
203
342
  server.registerTool(note("search"), {
204
343
  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
- },
344
+ 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. " +
345
+ `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.`,
346
+ inputSchema: z.object({
347
+ query: z.string().min(1).max(500).describe("Free-text search term, e.g. 'button' or 'date picker'. Every word must match."),
348
+ limit: NUM.int().positive().optional().describe(`Hits per call (default ${SEARCH_DEFAULT_LIMIT}, at most ${SEARCH_MAX_LIMIT}).`),
349
+ offset: NUM.int().min(0).optional().describe("Hits to skip, for paging. Use meta.next_offset from the previous call."),
350
+ }),
351
+ outputSchema: z.object({
352
+ results: z.object({
353
+ data: z.array(z.looseObject({
354
+ kind: z.string(),
355
+ name: z.string(),
356
+ title: z.string().optional(),
357
+ description: z.string().optional(),
358
+ rustCrate: z.string().optional(),
359
+ astroFile: z.string().optional(),
360
+ })),
361
+ meta: z.looseObject({
362
+ total: z.number(),
363
+ count: z.number(),
364
+ offset: z.number(),
365
+ has_more: z.boolean(),
366
+ next_offset: z.number().optional(),
367
+ }),
368
+ }),
369
+ }),
209
370
  annotations: READ,
210
- }, async ({ query }) => respond((await reg()).search(query), "results"));
371
+ }, async ({ query, limit, offset }) => {
372
+ const res = (await reg()).search(query, Number.MAX_SAFE_INTEGER);
373
+ const all = res.data.data;
374
+ const skip = offset ?? 0;
375
+ const take = Math.min(limit ?? SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
376
+ const render = (page) => {
377
+ const next = skip + page.length;
378
+ const hasMore = next < all.length;
379
+ return {
380
+ data: page,
381
+ meta: {
382
+ ...res.data.meta,
383
+ total: all.length,
384
+ count: page.length,
385
+ offset: skip,
386
+ has_more: hasMore,
387
+ ...(hasMore ? { next_offset: next } : {}),
388
+ },
389
+ };
390
+ };
391
+ const { result } = fitToLimit(all.slice(skip, skip + take), render);
392
+ return jsonContent(result, "results");
393
+ });
211
394
  // 2 ─ get_component — one read, everything, Rust first
212
395
  server.registerTool(note("get_component"), {
213
396
  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\". " +
397
+ 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
398
  "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'."),
399
+ inputSchema: z.object({
400
+ name: z.string().max(200).min(1).describe("Component name, e.g. 'button'."),
218
401
  include: z
219
402
  .array(z.enum(["docs", "versions"]))
220
403
  .optional()
221
404
  .describe("Extra sections to fetch. Omit for the implementations only."),
222
- },
405
+ }),
223
406
  annotations: READ,
224
407
  }, async ({ name, include }) => {
225
408
  const r = await reg();
@@ -237,10 +420,12 @@ export function registerMziziTools(server, opts) {
237
420
  note: rust.ok
238
421
  ? `Rust first: "${current}" has a Mzizi Roots implementation in the ${rust.data.crate.name} crate — use it. ` +
239
422
  "The React build below is deprioritised: it keeps working, but new work goes to Rust."
240
- : `No Mzizi Roots (Rust) implementation yet. ${rust.message}`,
423
+ : rust.message,
241
424
  rust: rust.ok ? rustSection(rust.data, r.pin.ref) : null,
242
425
  astro: astro.ok ? astroSection(astro.data) : null,
243
- react: react.ok ? reactSection(react.data) : { build: "react", error: react.message },
426
+ react: react.ok
427
+ ? await reactSection(react.data, r)
428
+ : { build: "react", error: react.message },
244
429
  };
245
430
  // A failure in an extra section must not discard the component the caller
246
431
  // actually asked for -- it is reported alongside it.
@@ -259,8 +444,8 @@ export function registerMziziTools(server, opts) {
259
444
  "Returns a lean row per component (name, type, node, owner, collection, and rustCrate " +
260
445
  "when it has a Mzizi Roots (Rust) implementation, which is the lead) — use " +
261
446
  "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: {
447
+ `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.`,
448
+ inputSchema: z.object({
264
449
  // No .max(): the node set is uncapped, and a Zod bound would reject a filter
265
450
  // before it reached the data, turning a new node into a schema error.
266
451
  node: NUM.int()
@@ -268,15 +453,15 @@ export function registerMziziTools(server, opts) {
268
453
  .optional()
269
454
  .describe("Filter by node number (uncapped)."),
270
455
  owner: z
271
- .string()
456
+ .string().max(100)
272
457
  .optional()
273
458
  .describe("Filter by owner: mzizi | nyuchi | bundu | framework."),
274
459
  collection: z
275
- .string()
460
+ .string().max(100)
276
461
  .optional()
277
462
  .describe("Filter by collection, e.g. primitives | brand | pages | resilience."),
278
463
  type: z
279
- .string()
464
+ .string().max(100)
280
465
  .optional()
281
466
  .describe("Filter by registry type, e.g. registry:ui | registry:lib | registry:block."),
282
467
  rust: BOOL.optional().describe("true: only components with a Mzizi Roots (Rust) implementation; false: only those without."),
@@ -284,12 +469,30 @@ export function registerMziziTools(server, opts) {
284
469
  limit: NUM.int()
285
470
  .positive()
286
471
  .optional()
287
- .describe(`Rows per call (default ${LIST_DEFAULT_LIMIT}).`),
472
+ .describe(`Rows per call (default ${LIST_DEFAULT_LIMIT}). A page also stops at ${CHARACTER_LIMIT} characters.`),
288
473
  offset: NUM.int()
289
474
  .min(0)
290
475
  .optional()
291
476
  .describe("Rows to skip, for paging."),
292
- },
477
+ }),
478
+ outputSchema: z.object({
479
+ count: z.number(),
480
+ total: z.number(),
481
+ offset: z.number(),
482
+ has_more: z.boolean(),
483
+ next_offset: z.number().optional(),
484
+ hasMore: z.boolean(),
485
+ nextOffset: z.number().optional(),
486
+ hint: z.string().optional(),
487
+ registryTotal: z.number().optional(),
488
+ withRust: z.number(),
489
+ withAstro: z.number(),
490
+ items: z.array(z.looseObject({
491
+ name: z.unknown(),
492
+ rustCrate: z.string().optional(),
493
+ astroFile: z.string().optional(),
494
+ })),
495
+ }),
293
496
  annotations: READ,
294
497
  }, async ({ node, owner, collection, type, rust, astro, limit, offset }) => {
295
498
  const r = await reg();
@@ -329,40 +532,47 @@ export function registerMziziTools(server, opts) {
329
532
  ...(astroFile ? { astroFile } : {}),
330
533
  };
331
534
  });
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
- });
535
+ const render = (page) => {
536
+ const returned = skip + page.length;
537
+ const more = returned < total;
538
+ return {
539
+ count: page.length,
540
+ total,
541
+ offset: skip,
542
+ has_more: more,
543
+ ...(more ? { next_offset: returned } : {}),
544
+ // camelCase kept for clients written against 0.13 and earlier.
545
+ hasMore: more,
546
+ ...(more
547
+ ? {
548
+ nextOffset: returned,
549
+ hint: `Call again with offset=${returned}, or narrow with node/owner/collection.`,
550
+ }
551
+ : {}),
552
+ // Present so a caller can distinguish "your filter matched nothing" from "the
553
+ // registry is empty" — the two look identical in an empty items array, and the
554
+ // first is a query to rewrite while the second is an outage.
555
+ ...(registryTotal !== undefined ? { registryTotal } : {}),
556
+ // How many of the `total` matched rows have a Mzizi Roots (Rust)
557
+ // implementation. Filter with rust=true to list only those.
558
+ withRust: payload.meta?.withRust ?? 0,
559
+ // How many have an Astro implementation. Filter with astro=true to list only those.
560
+ withAstro: payload.meta?.withAstro ?? 0,
561
+ items: page,
562
+ };
563
+ };
564
+ return jsonContent(fitToLimit(items, render).result);
355
565
  });
356
566
  // 4 ─ get_tokens
357
567
  server.registerTool(note("get_tokens"), {
358
568
  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: {
569
+ 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. 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).",
570
+ inputSchema: z.object({
361
571
  family: z
362
- .string()
572
+ .string().max(100)
363
573
  .optional()
364
574
  .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
- },
575
+ }),
366
576
  annotations: READ,
367
577
  }, async ({ family }) => {
368
578
  const res = (await reg()).getBrand();
@@ -455,12 +665,12 @@ export function registerMziziTools(server, opts) {
455
665
  server.registerTool(note("get_architecture"), {
456
666
  title: "Get the architecture",
457
667
  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: {
668
+ inputSchema: z.object({
459
669
  node: NUM.int()
460
670
  .positive()
461
671
  .optional()
462
672
  .describe("One node's detail (uncapped)."),
463
- },
673
+ }),
464
674
  annotations: READ,
465
675
  }, async ({ node }) => {
466
676
  const r = await reg();
@@ -470,15 +680,15 @@ export function registerMziziTools(server, opts) {
470
680
  server.registerTool(note("get_doctrine"), {
471
681
  title: "Get doctrine",
472
682
  description: "How to build here: Ubuntu pillars and principles, mzizi conventions, and the AI instruction sets.",
473
- inputSchema: {
683
+ inputSchema: z.object({
474
684
  topic: z
475
685
  .enum(["ubuntu-pillars", "ubuntu-principles", "ai-instructions", "conventions"])
476
686
  .describe("Which doctrine to read."),
477
687
  name: z
478
- .string()
688
+ .string().max(200)
479
689
  .optional()
480
690
  .describe("For ai-instructions, the name or target to fetch; for conventions, the convention's name. Omit to list."),
481
- },
691
+ }),
482
692
  annotations: READ,
483
693
  }, async ({ topic, name }) => {
484
694
  const r = await reg();
@@ -493,14 +703,18 @@ export function registerMziziTools(server, opts) {
493
703
  // 7 ─ get_skills — served from the bundle this Worker ships with
494
704
  server.registerTool(note("get_skills"), {
495
705
  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."),
706
+ 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.",
707
+ inputSchema: z.object({
708
+ name: z.string().max(200).optional().describe("Skill name. Omit to list all."),
709
+ file: z
710
+ .string().max(500)
711
+ .optional()
712
+ .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
713
  category: z
500
714
  .enum(SKILL_CATEGORIES)
501
715
  .optional()
502
716
  .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
- },
717
+ }),
504
718
  annotations: READ,
505
719
  },
506
720
  // No network call, and that is the point. This used to read
@@ -509,7 +723,9 @@ export function registerMziziTools(server, opts) {
509
723
  // instructing agents to write into a database column that had been cleared.
510
724
  // The Worker now answers from the bundle it was built with: one hop, and a
511
725
  // merge is the only way the answer changes.
512
- async ({ name, category }) => {
726
+ async ({ name, category, file }) => {
727
+ if (file && !name)
728
+ return toolError("`file` needs `name`: the skill whose file it is.");
513
729
  if (name) {
514
730
  const skill = BUNDLED_SKILLS.find((s) => s.name === name);
515
731
  if (!skill) {
@@ -518,7 +734,14 @@ export function registerMziziTools(server, opts) {
518
734
  if (category && skill.category !== category) {
519
735
  return toolError(`Skill "${name}" is in category "${skill.category}", not "${category}".`);
520
736
  }
521
- return jsonContent({ version: SKILLS_VERSION, ...skill }, "skills");
737
+ if (file) {
738
+ const f = skill.files.find((x) => x.path === file);
739
+ if (!f) {
740
+ return toolError(`Skill "${name}" ships no file "${file}". Its files: ${skill.files.map((x) => x.path).join(", ") || "none"}.`);
741
+ }
742
+ 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");
743
+ }
744
+ return jsonContent({ version: SKILLS_VERSION, ...skillView(skill) }, "skills");
522
745
  }
523
746
  if (category) {
524
747
  // A category is a working set an agent loads together (category=dev is
@@ -526,7 +749,7 @@ export function registerMziziTools(server, opts) {
526
749
  return jsonContent({
527
750
  version: SKILLS_VERSION,
528
751
  category,
529
- skills: BUNDLED_SKILLS.filter((s) => s.category === category),
752
+ skills: BUNDLED_SKILLS.filter((s) => s.category === category).map(skillView),
530
753
  }, "skills");
531
754
  }
532
755
  // The list omits bodies — seven full skills is ~170 kB, and a client
@@ -534,22 +757,31 @@ export function registerMziziTools(server, opts) {
534
757
  return jsonContent({
535
758
  version: SKILLS_VERSION,
536
759
  categories: SKILL_CATEGORIES,
537
- skills: BUNDLED_SKILLS.map(({ name: n, category: c, description, source }) => ({
760
+ skills: BUNDLED_SKILLS.map(({ name: n, category: c, description, source, files }) => ({
538
761
  name: n,
539
762
  category: c,
540
763
  description,
541
764
  source,
765
+ uri: skillUri(n),
766
+ files: files.map((f) => f.path),
542
767
  })),
543
768
  }, "skills");
544
769
  });
545
770
  // 8 ─ check_accessibility
546
771
  server.registerTool(note("check_accessibility"), {
547
772
  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
- },
773
+ description: "The WCAG 2.x contrast ratio of a foreground on a background colour, with the AA-large, AA and AAA verdicts and whether it meets the Mzizi floor of 7:1. 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.",
774
+ inputSchema: z.object({
775
+ foreground: z.string().max(16).describe("Foreground colour as #RRGGBB, e.g. #1B1A17."),
776
+ background: z.string().max(16).describe("Background colour as #RRGGBB, e.g. #FFFFFF."),
777
+ }),
778
+ outputSchema: z.object({
779
+ foreground: z.string(),
780
+ background: z.string(),
781
+ contrastRatio: z.number(),
782
+ passes: z.object({ aaLarge: z.boolean(), aa: z.boolean(), aaa: z.boolean() }),
783
+ meetsMziziFloor: z.boolean(),
784
+ }),
553
785
  annotations: READ,
554
786
  }, async ({ foreground, background }) => {
555
787
  const parse = (hex) => {
@@ -593,7 +825,7 @@ export function registerMziziTools(server, opts) {
593
825
  server.registerTool(note("report_issue"), {
594
826
  title: "Report an issue",
595
827
  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: {
828
+ inputSchema: z.object({
597
829
  action: z
598
830
  .enum(["file", "list", "get", "retry"])
599
831
  .default("file")
@@ -603,24 +835,24 @@ export function registerMziziTools(server, opts) {
603
835
  .optional()
604
836
  .describe("Report kind. Required for action 'file'."),
605
837
  component: z
606
- .string()
838
+ .string().max(200)
607
839
  .optional()
608
840
  .describe("Component the report concerns."),
609
841
  message: z
610
- .string()
842
+ .string().max(20000)
611
843
  .optional()
612
844
  .describe("What happened. Required for action 'file'."),
613
845
  severity: z.enum(["low", "medium", "high", "critical"]).optional(),
614
846
  skill: z
615
- .string()
847
+ .string().max(100)
616
848
  .optional()
617
849
  .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
850
  reportId: z
619
- .string()
851
+ .string().max(200)
620
852
  .optional()
621
853
  .describe("Report id. Required for actions 'get' and 'retry'."),
622
854
  limit: z.number().int().min(1).max(100).optional(),
623
- },
855
+ }),
624
856
  annotations: WRITE,
625
857
  }, async (args) => {
626
858
  const desk = opts.desk;
@@ -673,21 +905,21 @@ export function registerMziziTools(server, opts) {
673
905
  server.registerTool(note("fundi"), {
674
906
  title: "fundi",
675
907
  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: {
908
+ inputSchema: z.object({
677
909
  action: z.enum(["submit", "task_status", "cancel"]),
678
910
  skill: z
679
911
  .enum(["security-scan", "chaos-run", "a11y-audit", "heal-issue"])
680
912
  .optional()
681
913
  .describe("Required for submit."),
682
914
  instruction: z
683
- .string()
915
+ .string().max(20000)
684
916
  .optional()
685
917
  .describe("What fundi should do. Required for submit."),
686
918
  taskId: z
687
- .string()
919
+ .string().max(200)
688
920
  .optional()
689
921
  .describe("Required for task_status and cancel."),
690
- },
922
+ }),
691
923
  annotations: { ...WRITE, destructiveHint: true },
692
924
  }, async ({ action, skill, instruction, taskId }) => {
693
925
  if (!f)