@nyuchi/mzizi-mcp 0.6.0 → 0.8.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/dist/server.js CHANGED
@@ -1,457 +1,32 @@
1
1
  /**
2
- * Mzizi MCP — registry-driven server factory.
2
+ * The Mzizi MCP server.
3
3
  *
4
- * The tool surface is NOT hardcoded. It is read at startup from the public
5
- * `mcp_tool_registry` table (the same registry that powers the design system's
6
- * MCP), and each tool is dispatched dynamically to its backing implementation:
4
+ * Backed by `mzizi.dev/api/v1` over HTTP. There is no Supabase client here.
7
5
  *
8
- * • `sql_function` → `supabase.rpc(fn, args)` (the common case)
9
- * • `edge_function` → `supabase.functions.invoke(fn, { body: args })`
10
- * • `source_table` → `supabase.from(table).select()` (fallback)
6
+ * WHAT CHANGED AND WHY. This server used to build its tool catalog at startup from the
7
+ * `mcp_tool_registry` table and dispatch each call to a Supabase RPC, edge function or
8
+ * table read — 62 enabled tools, defined as data. Two things were wrong with that:
11
9
  *
12
- * This makes mzizi-mcp a drop-in replacement for the legacy design MCP: adding,
13
- * renaming, or retiring a tool is a registry edit, not a code change. Argument
14
- * names in the registry's `input_schema` (and in the curated fallback schemas
15
- * below) match the SQL function parameters exactly, so arguments pass straight
16
- * through to `rpc()` with no remapping.
10
+ * 1. Components are not rows. They are real files in `nyuchi/mzizi` that the Next.js
11
+ * app compiles and typechecks, so an error in one fails the build. The app serves
12
+ * them over `/api/v1/*`, which makes that API the source of truth and this server
13
+ * one of its clients. Reading the database put a second, uncompiled representation
14
+ * in the path — one that could disagree with the code while still looking correct.
17
15
  *
18
- * The factory takes a pre-built `SupabaseClient` so the same factory works
19
- * across transports (HTTP per-request anon client; stdio long-lived anon
20
- * client). RLS grants public read on `mcp_tool_registry`, `component_documents`,
21
- * and EXECUTE on the document/registry RPCs.
22
- */
23
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
25
- import { FUNDI_A2A_TOOLS, dispatchFundiA2A, isFundiA2AToolName, } from "./fundi-a2a.js";
26
- import { jsonContent, toolError } from "./tool-result.js";
27
- export const MZIZI_MCP_VERSION = "0.6.0";
28
- /**
29
- * Every tool this server exposes is prefixed with `mzizi_` so the surface is
30
- * unambiguous when mounted alongside other MCP servers (see the mcp-builder
31
- * "service prefix" guideline). The prefix is applied at the boundary only:
32
- * registry rows, core-tool dispatch, and the backing SQL/edge/table names all
33
- * stay unprefixed, and the CallTool handler strips the prefix before dispatch.
34
- */
35
- const TOOL_PREFIX = "mzizi_";
36
- /** Human-readable title from a (possibly prefixed) tool name: `get_component` → "Get component". */
37
- function titleFor(name) {
38
- const words = name
39
- .replace(new RegExp(`^${TOOL_PREFIX}`), "")
40
- .replace(/_/g, " ")
41
- .trim();
42
- return words.charAt(0).toUpperCase() + words.slice(1);
43
- }
44
- const READ_ONLY_HINTS = {
45
- readOnlyHint: true,
46
- destructiveHint: false,
47
- idempotentHint: true,
48
- openWorldHint: false,
49
- };
50
- /**
51
- * Defaults for a mutating tool: not read-only, but append-only (never deletes
52
- * or overwrites → `destructiveHint` false) and not idempotent (each call makes
53
- * a new row). Also the fallback for `action`-kind tools, which run/compute
54
- * something and are likewise neither read-only nor idempotent.
55
- */
56
- const WRITE_HINTS = {
57
- readOnlyHint: false,
58
- destructiveHint: false,
59
- idempotentHint: false,
60
- openWorldHint: false,
61
- };
62
- /**
63
- * The only write-kind tools allowed onto the anonymous public surface: the
64
- * feedback sinks that either run SECURITY DEFINER (bypassing RLS safely) or
65
- * self-gate to an allow-list. Every other write stays off this surface and is
66
- * reached through an authenticated path (fundi worker / first-party caller).
67
- * • submit_component_feedback — report a failing/misconfigured component (N9)
68
- * • record_observability_event — structural error/perf beacon fundi watches
69
- * • record_chaos_event — resilience-testing feedback (self-gates in prod)
70
- */
71
- const PUBLIC_WRITE_TOOLS = new Set([
72
- "submit_component_feedback",
73
- "record_observability_event",
74
- "record_chaos_event",
75
- ]);
76
- /**
77
- * Annotations for a CORE tool defined in this file (list_collections,
78
- * get_database_status, fundi_status, the registry-unavailable diagnostic).
79
- * These have no registry row, so their hints live here. Defaults to read-only;
80
- * pass overrides for the exceptions.
81
- */
82
- function annotationsFor(name, overrides = {}) {
83
- return { title: titleFor(name), ...READ_ONLY_HINTS, ...overrides };
84
- }
85
- /**
86
- * Annotations for a registry-backed tool, read from the `mcp_tool_registry`
87
- * annotation columns — the single source of truth. Each column that is null
88
- * falls back to the per-`tool_kind` default (`read` → READ_ONLY_HINTS, anything
89
- * else → WRITE_HINTS), and `title` falls back to the derived `titleFor`. This
90
- * keeps the emitted annotations honest for every kind, including `action` tools
91
- * (which compute/run and are correctly NOT read-only), without hardcoding any
92
- * per-tool behaviour in the server.
93
- */
94
- function annotationsFromRow(row) {
95
- const fallback = row.tool_kind === "read" ? READ_ONLY_HINTS : WRITE_HINTS;
96
- const pick = (col, def) => col ?? def;
97
- return {
98
- title: row.title ?? titleFor(row.tool_name),
99
- readOnlyHint: pick(row.read_only_hint, fallback.readOnlyHint),
100
- destructiveHint: pick(row.destructive_hint, fallback.destructiveHint),
101
- idempotentHint: pick(row.idempotent_hint, fallback.idempotentHint),
102
- openWorldHint: pick(row.open_world_hint, fallback.openWorldHint),
103
- };
104
- }
105
- /**
106
- * Namespaced `_meta` for a tool. The registry groups tools by `category`
107
- * (component, architecture, brand, skills, documents, release, a11y,
108
- * observability, fundi, …); surfacing it here lets clients organise the ~60
109
- * tools by function. The `mzizi.dev/` prefix keeps the key out of the reserved
110
- * `modelcontextprotocol.io/` namespace.
111
- */
112
- function metaFor(category) {
113
- return { "mzizi.dev/category": category ?? "uncategorised" };
114
- }
115
- /**
116
- * The registry stores each tool's return shape as an already-enveloped JSON
117
- * Schema object: `{ type:"object", properties:{ <key>: … }, required:[<key>] }`
118
- * where `<key>` is `rows` for the array-returning `RETURNS TABLE` functions and
119
- * `result` for scalar/jsonb returns. MCP requires `outputSchema` (and the
120
- * matching `structuredContent`) to be an object, so the RPC's raw array/scalar
121
- * result is wrapped under that single key at dispatch time. Returns the schema
122
- * to emit plus the wrap key, or null when no usable shape is stored.
123
- */
124
- function emittedOutput(raw) {
125
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
126
- return null;
127
- if (raw["type"] !== "object")
128
- return null;
129
- const props = raw["properties"];
130
- if (!props || typeof props !== "object")
131
- return null;
132
- const keys = Object.keys(props);
133
- if (keys.length === 0)
134
- return null;
135
- const required = Array.isArray(raw["required"])
136
- ? raw["required"]
137
- : [];
138
- const wrapKey = required[0] ?? keys[0];
139
- if (!wrapKey)
140
- return null;
141
- return { outputSchema: raw, wrapKey };
142
- }
143
- const str = (description) => ({
144
- type: ["string", "null"],
145
- description,
146
- });
147
- const reqStr = (description) => ({ type: "string", description });
148
- const int = (description) => ({
149
- type: ["integer", "null"],
150
- description,
151
- });
152
- const bool = (description) => ({
153
- type: ["boolean", "null"],
154
- description,
155
- });
156
- const num = (description) => ({
157
- type: ["number", "null"],
158
- description,
159
- });
160
- /**
161
- * Curated input schemas for the parameterized tools whose registry
162
- * `input_schema` is not (yet) populated. Property names are the exact SQL
163
- * function parameter names so `rpc()` receives them unchanged. No-arg tools are
164
- * omitted — they fall back to an empty-object schema.
16
+ * 2. A tool surface defined in a table changes whenever someone edits a row: no diff,
17
+ * no review, no test. The 10 tools live in `tools.ts` as code, so changing them is
18
+ * a pull request.
165
19
  *
166
- * NEVER put a `maximum` on a node argument, and never name a range in its
167
- * description. Nodes are labels, not a sequence, and the set only ever grows
168
- * (N11 discovery already exists) — a ceiling silently hides every node above it
169
- * and has to be chased forever. An unknown node returning an empty set is the
170
- * correct answer; a validation error is not. Use NODE_ARG_DESCRIPTION so this
171
- * cannot drift back one property at a time.
172
- */
173
- const NODE_ARG_DESCRIPTION = "Ecosystem node number. Nodes are labels, not a sequence, and are never " +
174
- "capped — read the documentation-architecture-nodes collection for the live set.";
175
- const INPUT_SCHEMAS = {
176
- // component
177
- get_component: {
178
- type: "object",
179
- properties: { p_name: reqStr("Component name, e.g. 'button'") },
180
- required: ["p_name"],
181
- },
182
- get_component_history: {
183
- type: "object",
184
- properties: {
185
- p_component_name: reqStr("Component name"),
186
- p_limit: int("Max rows (default 20)"),
187
- },
188
- required: ["p_component_name"],
189
- },
190
- get_component_links: {
191
- type: "object",
192
- properties: { p_name: reqStr("Component name") },
193
- required: ["p_name"],
194
- },
195
- list_components: {
196
- type: "object",
197
- properties: {
198
- p_node: int(NODE_ARG_DESCRIPTION),
199
- p_status: str("Status filter"),
200
- p_category: str("Category filter"),
201
- p_limit: int("Max rows (default 500)"),
202
- p_offset: int("Offset"),
203
- },
204
- },
205
- search_components: {
206
- type: "object",
207
- properties: {
208
- p_query: reqStr("Search query"),
209
- p_limit: int("Max rows (default 50)"),
210
- },
211
- required: ["p_query"],
212
- },
213
- get_node_categories: {
214
- type: "object",
215
- properties: { p_node: int(`${NODE_ARG_DESCRIPTION} Omit for all nodes.`) },
216
- },
217
- get_node_detail: {
218
- type: "object",
219
- properties: {
220
- p_node: int(`${NODE_ARG_DESCRIPTION} Omit for a summary of every node.`),
221
- },
222
- },
223
- // documents
224
- get_node_documents: {
225
- type: "object",
226
- properties: {
227
- p_node: int(NODE_ARG_DESCRIPTION),
228
- p_collection: str("Collection key, e.g. 'agentic-components'"),
229
- p_name: str("Exact document name"),
230
- p_owner: str("Owner: bundu | mzizi | framework | nyuchi"),
231
- p_limit: int("Max rows (default 1000)"),
232
- },
233
- },
234
- // skills
235
- get_skill: {
236
- type: "object",
237
- properties: { p_name: reqStr("Skill name") },
238
- required: ["p_name"],
239
- },
240
- // ai
241
- get_ai_instructions: {
242
- type: "object",
243
- properties: { p_name: reqStr("Target/instruction name, e.g. 'claude'") },
244
- required: ["p_name"],
245
- },
246
- list_ai_instructions: {
247
- type: "object",
248
- properties: { p_target: str("Filter by target") },
249
- },
250
- // brand
251
- get_brand_tokens: {
252
- type: "object",
253
- properties: { p_category: reqStr("Token category") },
254
- required: ["p_category"],
255
- },
256
- list_ecosystem_brands: {
257
- type: "object",
258
- properties: { p_adopter_type: str("Adopter type filter") },
259
- },
260
- // governance
261
- get_bundu_convention: {
262
- type: "object",
263
- properties: { p_slug: reqStr("Convention slug") },
264
- required: ["p_slug"],
265
- },
266
- // release
267
- get_changelog_entry: {
268
- type: "object",
269
- properties: { p_version: reqStr("Version, e.g. '7.0.0'") },
270
- required: ["p_version"],
271
- },
272
- get_component_status_history: {
273
- type: "object",
274
- properties: { p_name: reqStr("Component name") },
275
- required: ["p_name"],
276
- },
277
- get_release_with_diff: {
278
- type: "object",
279
- properties: { p_version: reqStr("Version") },
280
- required: ["p_version"],
281
- },
282
- compute_release_diff: {
283
- type: "object",
284
- properties: {
285
- p_since: reqStr("ISO timestamp (inclusive)"),
286
- p_until: str("ISO timestamp (default now)"),
287
- },
288
- required: ["p_since"],
289
- },
290
- list_changelog: {
291
- type: "object",
292
- properties: {
293
- p_limit: int("Max rows (default 50)"),
294
- p_offset: int("Offset"),
295
- },
296
- },
297
- list_tool_versions: {
298
- type: "object",
299
- properties: {
300
- p_tool_name: reqStr("Tool name"),
301
- p_limit: int("Max rows (default 20)"),
302
- },
303
- required: ["p_tool_name"],
304
- },
305
- // fundi (reads)
306
- get_healing_log: {
307
- type: "object",
308
- properties: {
309
- p_issue_id: int("Filter by issue id"),
310
- p_limit: int("Max rows (default 100)"),
311
- },
312
- },
313
- list_recent_fundi_issues: {
314
- type: "object",
315
- properties: {
316
- p_status: str("Status filter"),
317
- p_severity: str("Severity filter"),
318
- p_limit: int("Max rows (default 50)"),
319
- },
320
- },
321
- // resolver
322
- resolve_primitive: {
323
- type: "object",
324
- properties: {
325
- p_primitive_name: reqStr("Primitive name"),
326
- p_descriptor_slug: reqStr("Framework descriptor slug, e.g. 'react'"),
327
- },
328
- required: ["p_primitive_name", "p_descriptor_slug"],
329
- },
330
- list_primitive_sources: {
331
- type: "object",
332
- properties: { p_primitive_name: reqStr("Primitive name") },
333
- required: ["p_primitive_name"],
334
- },
335
- list_framework_descriptors: {
336
- type: "object",
337
- properties: { p_active_only: bool("Only active descriptors") },
338
- },
339
- // observability
340
- is_domain_allowed: {
341
- type: "object",
342
- properties: {
343
- p_domain: reqStr("Domain"),
344
- p_feature: str("Feature (default 'portal_links')"),
345
- },
346
- required: ["p_domain"],
347
- },
348
- list_observability_domains: {
349
- type: "object",
350
- properties: { p_feature: str("Feature filter") },
351
- },
352
- list_observability_events: {
353
- type: "object",
354
- properties: {
355
- p_domain: str("Domain filter"),
356
- p_component_name: str("Component filter"),
357
- p_event_type: str("Event-type filter"),
358
- p_limit: int("Max rows (default 100)"),
359
- },
360
- },
361
- // chaos (read)
362
- list_chaos_events: {
363
- type: "object",
364
- properties: {
365
- p_domain: str("Domain filter"),
366
- p_environment: str("Environment filter"),
367
- p_event_type: str("Event-type filter"),
368
- p_limit: int("Max rows (default 100)"),
369
- },
370
- },
371
- // a11y
372
- calculate_contrast_ratio: {
373
- type: "object",
374
- properties: {
375
- fg_hex: reqStr("Foreground hex, e.g. '#111111'"),
376
- bg_hex: reqStr("Background hex, e.g. '#ffffff'"),
377
- },
378
- required: ["fg_hex", "bg_hex"],
379
- },
380
- relative_luminance: {
381
- type: "object",
382
- properties: { hex: reqStr("Hex colour") },
383
- required: ["hex"],
384
- },
385
- simulate_color_blindness: {
386
- type: "object",
387
- properties: {
388
- hex: reqStr("Hex colour"),
389
- cb_type: reqStr("Type: protanopia | deuteranopia | tritanopia"),
390
- },
391
- required: ["hex", "cb_type"],
392
- },
393
- run_accessibility_audit: {
394
- type: "object",
395
- properties: {
396
- p_file_fundi_issues: bool("File fundi issues for failures (default true)"),
397
- p_contrast_floor: num("Minimum contrast ratio (default 3.0)"),
398
- },
399
- },
400
- // meta
401
- mcp_describe: {
402
- type: "object",
403
- properties: {
404
- p_category: str("Filter by category"),
405
- p_stability: str("Filter by stability"),
406
- p_tool_kind: str("Filter by tool kind"),
407
- },
408
- },
409
- };
410
- const EMPTY_SCHEMA = { type: "object", properties: {} };
411
- /**
412
- * Fetch every `(collection, node, owner)` row from the document store, paging
413
- * past PostgREST's max-rows cap (1000 by default). The store holds >3k rows;
414
- * a single unpaged select silently truncates the aggregate — counts come back
415
- * wrong and whole collections (shell, safety, agentic-components, …) vanish
416
- * from the surface. Ordered by the `(collection, name)` primary key so pages
417
- * are stable.
20
+ * The 10 are specified in `docs/mcp-tool-consolidation.md` (mzizi repo). Each carries its
21
+ * `replaces` list in `TOOL_COVERAGE`, so coverage of the old 62 is checkable rather than
22
+ * asserted.
418
23
  */
419
- async function fetchCollectionRows(supabase) {
420
- const rows = [];
421
- const page = 1000;
422
- for (let from = 0;; from += page) {
423
- const { data, error } = await supabase
424
- .from("component_documents")
425
- .select("collection, node, owner")
426
- .order("collection", { ascending: true })
427
- .order("name", { ascending: true })
428
- .range(from, from + page - 1);
429
- if (error)
430
- return { rows, error: error.message };
431
- const batch = data ?? [];
432
- rows.push(...batch);
433
- if (batch.length < page)
434
- return { rows, error: null };
435
- }
436
- }
437
- function hasProperties(schema) {
438
- const props = schema && schema.properties;
439
- return (!!props &&
440
- typeof props === "object" &&
441
- Object.keys(props).length > 0);
442
- }
443
- function schemaFor(row) {
444
- if (hasProperties(row.input_schema))
445
- return row.input_schema;
446
- return INPUT_SCHEMAS[row.tool_name] ?? EMPTY_SCHEMA;
447
- }
448
- /**
449
- * Build the Mzizi MCP server bound to a Supabase client with public read on
450
- * `mcp_tool_registry` / `component_documents` and EXECUTE on the backing RPCs.
451
- * The tool catalog is loaded from the registry; write-kind tools are excluded
452
- * from this (anonymous, public) surface.
453
- */
454
- export async function createMziziMcpServer(supabase, opts = {}) {
24
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
+ import { MziziApiClient } from "./api-client.js";
26
+ import { registerMziziTools, TOOL_COVERAGE, TOOL_PREFIX } from "./tools.js";
27
+ import { jsonContent } from "./tool-result.js";
28
+ export const MZIZI_MCP_VERSION = "0.8.0";
29
+ export async function createMziziMcpServer(opts = {}) {
455
30
  const server = new McpServer({
456
31
  name: "mzizi-mcp",
457
32
  version: MZIZI_MCP_VERSION,
@@ -461,271 +36,39 @@ export async function createMziziMcpServer(supabase, opts = {}) {
461
36
  tools: { listChanged: true },
462
37
  resources: { listChanged: true },
463
38
  },
464
- instructions: "Read-only access to the Mzizi design-system registry (a nyuchi-brand project of the Bundu ecosystem): components, design tokens, brand, the ecosystem architecture nodes, Ubuntu doctrine, skills, releases, and the agentic-components surface. The tool surface is registry-driven — call mcp_describe to enumerate tools and categories, list_collections to see document collections, get_component / list_components / search_components for components, and get_node_documents for everything under an ecosystem node or collection.",
39
+ instructions: "Access to the Mzizi design system (a nyuchi-brand project of the Bundu ecosystem) over its public API: components, design tokens, brand, the DNA-helix architecture, Ubuntu doctrine, agent skills and releases. Ten tools. Start with mzizi_search if you do not know the name of what you want; mzizi_get_component returns a component in full in one read; mzizi_get_architecture returns the helix and takes an uncapped node number.",
465
40
  });
466
- // ── Registry-driven tool catalog ───────────────────────────────────────────
467
- const { data, error } = await supabase
468
- .from("mcp_tool_registry")
469
- .select("tool_name, description, category, tool_kind, sql_function, source_table, edge_function, input_schema, output_shape, requires_first_party, read_only_hint, destructive_hint, idempotent_hint, open_world_hint, title")
470
- .eq("enabled", true);
471
- if (error) {
472
- // Surface the failure as a diagnostic tool rather than a silent empty server.
473
- server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
474
- tools: [
475
- {
476
- name: `${TOOL_PREFIX}registry_unavailable`,
477
- description: `The mcp_tool_registry could not be read: ${error.message}`,
478
- inputSchema: EMPTY_SCHEMA,
479
- annotations: annotationsFor("registry_unavailable"),
480
- },
481
- ],
482
- }));
483
- server.server.setRequestHandler(CallToolRequestSchema, async () => toolError(`mcp_tool_registry unavailable: ${error.message}`));
484
- return server;
485
- }
486
- const rows = (data ?? []).filter((r) => !r.requires_first_party &&
487
- (r.tool_kind !== "write" || PUBLIC_WRITE_TOOLS.has(r.tool_name)));
488
- const byName = new Map(rows.map((r) => [r.tool_name, r]));
489
- // Core document tools that are aggregates without an RPC, so they aren't
490
- // registry-backed. Everything else is registry-driven.
491
- const collectionsOutput = {
492
- type: "object",
493
- properties: {
494
- rows: {
495
- type: "array",
496
- items: {
497
- type: "object",
498
- properties: {
499
- collection: { type: "string" },
500
- node: { type: ["integer", "null"] },
501
- total: { type: "integer" },
502
- byOwner: { type: "object" },
503
- },
504
- },
41
+ const api = new MziziApiClient({ baseUrl: opts.apiBaseUrl, fetchImpl: opts.fetchImpl });
42
+ const registered = registerMziziTools(server, { api, fundi: opts.fundi });
43
+ const describe = () => ({
44
+ version: MZIZI_MCP_VERSION,
45
+ source: "https://mzizi.dev/api/v1",
46
+ note: "Components are files in nyuchi/mzizi compiled by the Next.js app and served over /api/v1. This server reads that API and holds no database credential.",
47
+ tools: registered.map((name) => ({
48
+ name: `${TOOL_PREFIX}${name}`,
49
+ replaces: TOOL_COVERAGE[name] ?? [],
50
+ })),
51
+ });
52
+ // Computed from what was actually registered, never a hand-maintained list — a
53
+ // hardcoded catalog is how the old registry drifted from the tools it described.
54
+ server.registerResource("mzizi-tools", "mzizi://tools", {
55
+ title: "Mzizi MCP tools",
56
+ description: "The tools this server exposes, and which legacy tools each replaces.",
57
+ mimeType: "application/json",
58
+ }, async (uri) => ({
59
+ contents: [
60
+ {
61
+ uri: uri.href,
62
+ mimeType: "application/json",
63
+ text: JSON.stringify(describe(), null, 2),
505
64
  },
506
- },
507
- required: ["rows"],
508
- };
509
- const databaseStatusOutput = {
510
- type: "object",
511
- properties: {
512
- provider: { type: "string" },
513
- status: { type: "string" },
514
- error: { type: ["string", "null"] },
515
- documents: { type: "integer" },
516
- registryTools: { type: "integer" },
517
- version: { type: "string" },
518
- readOnly: { type: "boolean" },
519
- },
520
- required: ["provider", "status", "documents", "registryTools", "version"],
521
- };
522
- const coreTools = [
523
- {
524
- name: `${TOOL_PREFIX}list_collections`,
525
- description: "List every collection in the Mzizi document store with document counts and per-owner breakdown (components, primitives, brand, agentic-components, skills, …).",
526
- inputSchema: EMPTY_SCHEMA,
527
- outputSchema: collectionsOutput,
528
- annotations: annotationsFor("list_collections"),
529
- _meta: metaFor("documents"),
530
- },
531
- {
532
- name: `${TOOL_PREFIX}get_database_status`,
533
- description: "Provider health, document-store row count, and registered-tool count.",
534
- inputSchema: EMPTY_SCHEMA,
535
- outputSchema: databaseStatusOutput,
536
- annotations: annotationsFor("get_database_status"),
537
- _meta: metaFor("system"),
538
- },
539
- ];
540
- // fundi trigger — exposed only when this Worker is wired as a fundi caller
541
- // (holds the M2M secret). fundi never runs in isolation: this routes the
542
- // signed-in user's request to fundi with an M2M token + the user's token.
543
- if (opts.fundi) {
544
- coreTools.push({
545
- name: `${TOOL_PREFIX}fundi_status`,
546
- description: "Check fundi — the self-healing agent — on behalf of the signed-in user. fundi is machine-to-machine and only runs through an authenticated tool; this routes your request through mzizi-mcp, which holds the M2M credential.",
547
- inputSchema: EMPTY_SCHEMA,
548
- // Reaches the external fundi worker → open-world; still a read-only whoami.
549
- annotations: annotationsFor("fundi_status", { openWorldHint: true }),
550
- _meta: metaFor("fundi"),
551
- });
552
- // The A2A bridge (docs/a2a-fundi.md §4): delegate a run to fundi as a task
553
- // and poll it. Same condition as fundi_status — a tool that always appears
554
- // and always fails is worse than an absent one.
555
- for (const def of FUNDI_A2A_TOOLS) {
556
- coreTools.push({
557
- name: `${TOOL_PREFIX}${def.name}`,
558
- description: def.description,
559
- inputSchema: def.inputSchema,
560
- outputSchema: def.outputSchema,
561
- annotations: annotationsFor(def.name, def.annotations),
562
- _meta: metaFor(def.category),
563
- });
564
- }
565
- }
566
- const tools = [
567
- ...rows.map((r) => {
568
- const emitted = emittedOutput(r.output_shape);
569
- return {
570
- name: `${TOOL_PREFIX}${r.tool_name}`,
571
- description: r.description ??
572
- `${r.category ?? "mzizi"} ${r.tool_kind ?? "read"} tool (registry-driven).`,
573
- inputSchema: schemaFor(r),
574
- ...(emitted
575
- ? {
576
- outputSchema: emitted.outputSchema,
577
- }
578
- : {}),
579
- annotations: annotationsFromRow(r),
580
- _meta: metaFor(r.category),
581
- };
582
- }),
583
- ...coreTools,
584
- ];
585
- server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
586
- tools,
65
+ ],
587
66
  }));
588
- server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
589
- // Tools are exposed with the `mzizi_` prefix; strip it back to the internal
590
- // (registry / core) name before dispatch. Tolerate an unprefixed call too.
591
- const requested = request.params.name;
592
- const name = requested.startsWith(TOOL_PREFIX)
593
- ? requested.slice(TOOL_PREFIX.length)
594
- : requested;
595
- const args = (request.params.arguments ?? {});
596
- // Core document tools (aggregates without an RPC).
597
- if (name === "list_collections") {
598
- const { rows, error } = await fetchCollectionRows(supabase);
599
- if (error)
600
- return toolError(`list_collections failed: ${error}`);
601
- return jsonContent(summariseCollections(rows), "rows");
602
- }
603
- if (name === "get_database_status") {
604
- const docs = await supabase
605
- .from("component_documents")
606
- .select("*", { count: "exact", head: true });
607
- const toolsCount = await supabase
608
- .from("mcp_tool_registry")
609
- .select("*", { count: "exact", head: true })
610
- .eq("enabled", true);
611
- return jsonContent({
612
- provider: "supabase",
613
- status: docs.error ? "error" : "connected",
614
- error: docs.error?.message,
615
- documents: docs.count ?? 0,
616
- registryTools: toolsCount.count ?? 0,
617
- version: MZIZI_MCP_VERSION,
618
- readOnly: true,
619
- });
620
- }
621
- if (name === "fundi_status") {
622
- if (!opts.fundi)
623
- return toolError("fundi is not configured on this server");
624
- const res = await opts.fundi.caller.call("/admin/whoami", opts.fundi.userAccessToken);
625
- const body = await res.text();
626
- if (!res.ok)
627
- return toolError(`fundi call failed (${res.status}): ${body}`);
628
- try {
629
- return jsonContent(JSON.parse(body));
630
- }
631
- catch {
632
- return jsonContent({ ok: true, raw: body });
633
- }
634
- }
635
- if (isFundiA2AToolName(name)) {
636
- if (!opts.fundi)
637
- return toolError("fundi is not configured on this server");
638
- return dispatchFundiA2A(name, args, opts.fundi);
639
- }
640
- const row = byName.get(name);
641
- if (!row)
642
- return toolError(`Unknown tool: ${name}`);
643
- const wrapKey = emittedOutput(row.output_shape)?.wrapKey ?? null;
644
- try {
645
- if (row.sql_function) {
646
- const fn = row.sql_function.replace(/^public\./, "");
647
- const { data, error } = await supabase.rpc(fn, args);
648
- if (error)
649
- return toolError(`${row.tool_name} failed: ${error.message}`);
650
- return jsonContent(data, wrapKey);
651
- }
652
- if (row.edge_function) {
653
- const { data, error } = await supabase.functions.invoke(row.edge_function, { body: args });
654
- if (error)
655
- return toolError(`${row.tool_name} (edge) failed: ${error.message}`);
656
- return jsonContent(data, wrapKey);
657
- }
658
- if (row.source_table) {
659
- const { data, error } = await supabase
660
- .from(row.source_table)
661
- .select("*")
662
- .limit(200);
663
- if (error)
664
- return toolError(`${row.tool_name} failed: ${error.message}`);
665
- return jsonContent(data, wrapKey);
666
- }
667
- return toolError(`${row.tool_name} has no dispatch target in the registry.`);
668
- }
669
- catch (err) {
670
- return toolError(`${row.tool_name} threw: ${err instanceof Error ? err.message : String(err)}`);
671
- }
672
- });
673
- // ── Resources ──────────────────────────────────────────────────────────────
674
- server.resource("collections", "mzizi://collections", {
675
- description: "Index of every collection in the Mzizi document store with document counts and ownership breakdown.",
676
- }, async () => {
677
- const { rows } = await fetchCollectionRows(supabase);
678
- return {
679
- contents: [
680
- {
681
- uri: "mzizi://collections",
682
- mimeType: "application/json",
683
- text: JSON.stringify(summariseCollections(rows), null, 2),
684
- },
685
- ],
686
- };
687
- });
688
- server.resource("components", "mzizi://components", {
689
- description: "Lean index of the `components` collection (name / node / owner / status). Full documents via the get_component tool.",
690
- }, async () => {
691
- const { data } = await supabase.rpc("read_documents", {
692
- p_collection: "components",
693
- p_limit: 1000,
694
- });
695
- const rows = data ?? [];
696
- const lean = rows.map((row) => ({
697
- name: row.name,
698
- owner: row.owner,
699
- node: row.document?.["node"] ?? null,
700
- status: row.document?.["status"] ?? null,
701
- description: row.document?.["description"] ?? null,
702
- }));
703
- return {
704
- contents: [
705
- {
706
- uri: "mzizi://components",
707
- mimeType: "application/json",
708
- text: JSON.stringify(lean, null, 2),
709
- },
710
- ],
711
- };
712
- });
67
+ server.registerTool(`${TOOL_PREFIX}mcp_describe`, {
68
+ title: "Describe this server",
69
+ description: "What this MCP server exposes: its tools, what each replaces, and where its data comes from.",
70
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
71
+ }, async () => jsonContent(describe()));
713
72
  return server;
714
73
  }
715
- /** Aggregate `(collection, node, owner)` rows into a per-collection summary. */
716
- function summariseCollections(rows) {
717
- const summary = new Map();
718
- for (const row of rows) {
719
- const entry = summary.get(row.collection) ?? {
720
- collection: row.collection,
721
- node: row.node,
722
- total: 0,
723
- byOwner: {},
724
- };
725
- entry.total += 1;
726
- entry.byOwner[row.owner] = (entry.byOwner[row.owner] ?? 0) + 1;
727
- summary.set(row.collection, entry);
728
- }
729
- return [...summary.values()].sort((a, b) => a.collection.localeCompare(b.collection));
730
- }
731
74
  //# sourceMappingURL=server.js.map