@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.
- package/README.md +112 -7
- package/dist/docs-federation.d.ts +1 -1
- package/dist/docs-federation.d.ts.map +1 -1
- package/dist/docs-federation.js +1 -0
- package/dist/docs-federation.js.map +1 -1
- package/dist/fundi-client.d.ts +6 -0
- package/dist/fundi-client.d.ts.map +1 -1
- package/dist/fundi-client.js +14 -3
- package/dist/fundi-client.js.map +1 -1
- package/dist/generated/registry-pin.d.ts +1 -1
- package/dist/generated/registry-pin.js +1 -1
- package/dist/generated/registry.d.ts +2 -2
- package/dist/generated/registry.d.ts.map +1 -1
- package/dist/generated/registry.js +254 -198
- package/dist/generated/registry.js.map +1 -1
- package/dist/generated/skills.d.ts +11 -1
- package/dist/generated/skills.d.ts.map +1 -1
- package/dist/generated/skills.js +213 -18
- package/dist/generated/skills.js.map +1 -1
- package/dist/http.d.ts +22 -2
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +116 -19
- package/dist/http.js.map +1 -1
- package/dist/limits.d.ts +32 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +44 -0
- package/dist/limits.js.map +1 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +22 -1
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +37 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +96 -3
- package/dist/server.js.map +1 -1
- package/dist/stdio.js +5 -4
- package/dist/stdio.js.map +1 -1
- package/dist/tools.d.ts +21 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +307 -75
- package/dist/tools.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +3 -0
- package/dist/worker.js.map +1 -1
- 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
|
-
/**
|
|
184
|
-
function
|
|
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
|
-
|
|
207
|
-
|
|
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 }) =>
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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,
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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: "
|
|
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)
|