@adia-ai/mcp 0.8.37 → 0.8.39

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.
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env python3
2
+ """record-lint — mechanical gate for app-planning's Orientation Record.
3
+
4
+ The record is app-planning's typed deliverable (SKILL.md §The Orientation
5
+ Record) and the app-planning-agent → screen-composition-agent handoff artifact. Until
6
+ this linter existed, nothing checked a record's shape — and the record's
7
+ three in-tree consumers had already drifted from the contract (the
8
+ factory-audit finding this closes, gh#259 Wave 2).
9
+
10
+ Checks (the contract's mechanizable slice — shape, enums, evidence,
11
+ route-legality; whether a SIGNAL is genuinely load-bearing stays the
12
+ model's judgment):
13
+ 1. Required lines present: Rendering mode / Project shape / Shell /
14
+ Task / → Route / Verify target / Open questions. Screen plan is
15
+ conditional (start mode) — its absence is never an error.
16
+ 2. Axis enums: mode ∈ {SPA, SSR, hybrid} · shape ∈ {single-surface,
17
+ rollup, shared-foundation} · shell ∈ {admin, chat, editor, simple,
18
+ embed, none}.
19
+ 3. Every axis line carries a non-empty `— signal:` clause that isn't a
20
+ bare restatement of the value.
21
+ 4. Route names only skills that exist in this plugin (the task table's
22
+ roster), or app-planning itself.
23
+ 5. Every Open-questions entry names its fallback (`fallback:`).
24
+ 6. Verify target non-empty and not TBD-ish.
25
+ 7. Domain Plan (gh#1207, REQ-02) — CONDITIONAL: absent is never an
26
+ error (spec-shaped input only, mirrors Screen plan's conditionality).
27
+ When a `## Domain Plan` heading IS present, its required sub-fields
28
+ (Intent / Domain / Roles / Tasks / Decisions / Wireframe) must each
29
+ appear non-empty, and Wireframe must name a checkpoint verdict
30
+ (PASSED or FAILED).
31
+
32
+ Usage:
33
+ record-lint <file> # lint a file containing a record
34
+ record-lint - # lint stdin
35
+ record-lint selftest # embedded fixtures; exit 0 iff all pass
36
+ Exit 1 on findings, 0 clean. Stdlib only (Python 3.8+).
37
+ """
38
+ import re
39
+ import sys
40
+
41
+ AXES = {
42
+ 'Rendering mode': {'SPA', 'SSR', 'hybrid'},
43
+ 'Project shape': {'single-surface', 'rollup', 'shared-foundation'},
44
+ 'Shell': {'admin', 'chat', 'editor', 'simple', 'embed', 'none'},
45
+ }
46
+ REQUIRED = ['Rendering mode', 'Project shape', 'Shell', 'Task', '→ Route', 'Verify target', 'Open questions']
47
+ ROUTE_SKILLS = {
48
+ 'app-planning', 'project-scaffolding', 'screen-composition', 'shell-selection', 'host-wiring',
49
+ 'data-wiring', 'llm-wiring', 'gen-ui-wiring', 'surface-qa', 'app-migration',
50
+ }
51
+ TBD_RE = re.compile(r'^\s*(tbd|todo|\?+|—|-)\s*$', re.I)
52
+
53
+ # gh#1207 REQ-02 — the Orientation Record's CONDITIONAL Domain Plan block
54
+ # (spec-shaped input only; app-planning SKILL.md §The Orientation Record).
55
+ # Shape (sketch: .claude/docs/specs/factory-roster-completion.md):
56
+ # ## Domain Plan (rungs 0–19, ladder vX)
57
+ # - Intent: ... - Domain: ... - Roles: ... - Tasks: ... - Decisions: ...
58
+ # - Wireframe: ... checkpoint PASSED|FAILED
59
+ DOMAIN_PLAN_HEADING_RE = re.compile(r'^##\s*Domain Plan\b.*$', re.M)
60
+ DOMAIN_PLAN_FIELDS = ['Intent', 'Domain', 'Roles', 'Tasks', 'Decisions', 'Wireframe']
61
+ DOMAIN_PLAN_FIELD_RE = re.compile(
62
+ r'-\s*(' + '|'.join(DOMAIN_PLAN_FIELDS) + r')\s*:[ \t]*(.*?)'
63
+ r'(?=(?:[ \t]+-\s*(?:' + '|'.join(DOMAIN_PLAN_FIELDS) + r')\s*:)|\n|$)'
64
+ )
65
+
66
+
67
+ def lint(text):
68
+ findings = []
69
+ lines = {}
70
+ for raw in text.splitlines():
71
+ m = re.match(r'^\s*(→ Route|[A-Z][\w ]+?):\s*(.*)$', raw)
72
+ if m:
73
+ lines.setdefault(m.group(1).strip(), []).append(m.group(2).strip())
74
+
75
+ for label in REQUIRED:
76
+ if label not in lines:
77
+ findings.append(f"missing line: '{label}:'")
78
+
79
+ for axis, enum in AXES.items():
80
+ for value in lines.get(axis, []):
81
+ head = value.split('—')[0].strip()
82
+ sig = re.search(r'—\s*signal:\s*(.*)$', value)
83
+ if head not in enum:
84
+ findings.append(f"{axis}: '{head}' not in {sorted(enum)}")
85
+ if not sig or not sig.group(1).strip():
86
+ findings.append(f"{axis}: no '— signal:' clause (Evidence gate)")
87
+ elif head and head.lower() in sig.group(1).strip().lower() and len(sig.group(1).strip()) <= len(head) + 12:
88
+ findings.append(f"{axis}: signal restates the value ('{sig.group(1).strip()}')")
89
+
90
+ for value in lines.get('→ Route', []):
91
+ # kebab tokens that are either current factory skills or legacy
92
+ # adia-* names (the latter flag as not-a-factory-skill below, so an
93
+ # un-migrated record fails loudly instead of passing silently)
94
+ tokens = set(re.findall(r'[a-z][a-z0-9-]*[a-z0-9]', value))
95
+ named = {t for t in tokens if t in ROUTE_SKILLS or t.startswith('adia-')}
96
+ for skill in named - ROUTE_SKILLS:
97
+ findings.append(f"Route: '{skill}' is not a factory skill (Route-legal gate)")
98
+ if not named:
99
+ findings.append("Route: names no skill")
100
+
101
+ for value in lines.get('Verify target', []):
102
+ if not value or TBD_RE.match(value):
103
+ findings.append("Verify target: empty or TBD")
104
+
105
+ for value in lines.get('Open questions', []):
106
+ if value and not TBD_RE.match(value) and value.lower() not in ('none', 'blank if none', ''):
107
+ if 'fallback' not in value.lower():
108
+ findings.append(f"Open questions: entry lacks a named fallback: '{value[:50]}'")
109
+
110
+ findings.extend(_lint_domain_plan(text))
111
+ return findings
112
+
113
+
114
+ def _lint_domain_plan(text):
115
+ """gh#1207 REQ-02 — conditional Domain Plan block. Absence is never a
116
+ finding (never required); presence gates its own required sub-fields."""
117
+ heading = DOMAIN_PLAN_HEADING_RE.search(text)
118
+ if not heading:
119
+ return []
120
+ start = heading.end()
121
+ next_heading = re.search(r'\n##\s', text[start:])
122
+ block = text[start:start + next_heading.start()] if next_heading else text[start:]
123
+
124
+ found = {}
125
+ for fm in DOMAIN_PLAN_FIELD_RE.finditer(block):
126
+ found.setdefault(fm.group(1), fm.group(2).strip())
127
+
128
+ findings = []
129
+ for field in DOMAIN_PLAN_FIELDS:
130
+ if field not in found:
131
+ findings.append(f"Domain Plan: missing '{field}:' field")
132
+ elif not found[field]:
133
+ findings.append(f"Domain Plan: '{field}:' is empty")
134
+
135
+ wireframe = found.get('Wireframe', '')
136
+ if wireframe and not re.search(r'\b(PASSED|FAILED)\b', wireframe, re.I):
137
+ findings.append("Domain Plan: 'Wireframe:' names no checkpoint verdict (PASSED/FAILED)")
138
+
139
+ return findings
140
+
141
+
142
+ GOOD = """\
143
+ Rendering mode: SPA — signal: no framework dep in package.json; index.html present
144
+ Project shape: single-surface — signal: one entry under app/claims/
145
+ Shell: admin — signal: sidebar + topbar + command palette in the brief
146
+ Task: build a claims-review screen — signal: the request
147
+ Screen plan: 1. Claims list — reviewer scans and opens a claim
148
+ → Route: project-scaffolding, screen-composition, in order per the task table
149
+ Verify target: composed screen renders; adia-lint clean; browser gate
150
+ Open questions: auth model unclear — fallback: mock session until the API lands
151
+ """
152
+
153
+ BAD = """\
154
+ Rendering mode: Static — signal: SPA
155
+ Shell: admin
156
+ → Route: adia-scaffolding
157
+ Verify target: TBD
158
+ Open questions: auth model unclear
159
+ """
160
+
161
+ # gh#1207 REQ-02 — GOOD_WITH_DOMAIN_PLAN proves a spec-shaped record with a
162
+ # complete, well-formed Domain Plan block stays clean (the block is
163
+ # conditional, never required — GOOD above already proves a plain record
164
+ # with NO block is clean). Uses the spec sketch's own compressed
165
+ # "field: value - field: value" line shape for Roles/Tasks to prove the
166
+ # parser handles it, not just one-field-per-line.
167
+ GOOD_WITH_DOMAIN_PLAN = GOOD + """
168
+ ## Domain Plan (rungs 0–19, ladder v1)
169
+ - Intent: reviewers triage claims fast · cut time-to-resolution
170
+ - Domain: claim, adjuster, SLA · open-claims-count, avg-resolution-hours
171
+ - Roles: adjuster, supervisor - Tasks: triage queue · escalate stuck claims
172
+ - Decisions: assign / escalate / close, on priority + SLA risk
173
+ - Wireframe: sidebar + queue + drawer, D1-D6 all ≥ 3, checkpoint PASSED
174
+ """
175
+
176
+ # BAD_DOMAIN_PLAN: axis lines are fine (reuses GOOD) but the Domain Plan
177
+ # block is missing Decisions entirely, leaves Roles empty, and never names
178
+ # a checkpoint verdict — each must be caught independently.
179
+ BAD_DOMAIN_PLAN = GOOD + """
180
+ ## Domain Plan (rungs 0–19, ladder v1)
181
+ - Intent: reviewers triage claims fast · cut time-to-resolution
182
+ - Domain: claim, adjuster, SLA · open-claims-count, avg-resolution-hours
183
+ - Roles:
184
+ - Tasks: triage queue · escalate stuck claims
185
+ - Wireframe: sidebar + queue + drawer, D1-D6 all ≥ 3
186
+ """
187
+
188
+ # gh#1215 review F1 — the spec sketch (above, "Shape") separates compressed
189
+ # fields with a SINGLE space; the boundary lookahead must accept one-or-more
190
+ # spaces/tabs, not two-or-more, or a sketch-faithful record falsely fails.
191
+ GOOD_SINGLE_SPACE_PLAN = GOOD + """
192
+ ## Domain Plan (rungs 0–19, ladder v1)
193
+ - Intent: reviewers triage claims fast - Domain: claim, adjuster, SLA - Roles: adjuster, supervisor - Tasks: triage queue - Decisions: assign / escalate, on SLA risk
194
+ - Wireframe: sidebar + queue + drawer, checkpoint PASSED
195
+ """
196
+
197
+ GOOD_SINGLE_TAB_PLAN = GOOD + """
198
+ ## Domain Plan (rungs 0–19, ladder v1)
199
+ - Intent: reviewers triage claims fast - Domain: claim, adjuster, SLA - Roles: adjuster, supervisor - Tasks: triage queue - Decisions: assign / escalate, on SLA risk
200
+ - Wireframe: sidebar + queue + drawer, checkpoint PASSED
201
+ """
202
+
203
+
204
+ def _selftest():
205
+ import io
206
+
207
+ fails = []
208
+ if lint(GOOD):
209
+ fails.append(f"good fixture flagged: {lint(GOOD)}")
210
+ bad = lint(BAD)
211
+ expect = ['missing line', "not in", "no '— signal:'", 'not a factory skill', 'empty or TBD', 'lacks a named fallback']
212
+ for e in expect:
213
+ if not any(e in f for f in bad):
214
+ fails.append(f"bad fixture missed: {e} (got {bad})")
215
+
216
+ # gh#1207 REQ-02 — Domain Plan block: conditional-pass + presence-checks.
217
+ if lint(GOOD_WITH_DOMAIN_PLAN):
218
+ fails.append(f"good-with-domain-plan fixture flagged: {lint(GOOD_WITH_DOMAIN_PLAN)}")
219
+ # gh#1215 review F1 — single-space and single-tab compressed records
220
+ # (the sketch's own separator) must parse clean.
221
+ for name, fixture in (('single-space', GOOD_SINGLE_SPACE_PLAN), ('single-tab', GOOD_SINGLE_TAB_PLAN)):
222
+ got = lint(fixture)
223
+ if got:
224
+ fails.append(f'{name} compressed Domain Plan fixture flagged: {got}')
225
+ bad_plan = lint(BAD_DOMAIN_PLAN)
226
+ expect_plan = ["missing 'Decisions:'", "'Roles:' is empty", "names no checkpoint verdict"]
227
+ for e in expect_plan:
228
+ if not any(e in f for f in bad_plan):
229
+ fails.append(f"bad-domain-plan fixture missed: {e} (got {bad_plan})")
230
+
231
+ # REQ-05 (gh#1136): -h/--help exits 0 and prints usage — never a crash.
232
+ for flag in ('-h', '--help'):
233
+ saved_out = sys.stdout
234
+ sys.stdout = io.StringIO()
235
+ try:
236
+ rc_help = main(['record-lint', flag])
237
+ help_out = sys.stdout.getvalue()
238
+ finally:
239
+ sys.stdout = saved_out
240
+ if rc_help != 0:
241
+ fails.append(f'{flag} did not exit 0')
242
+ if not help_out.startswith('usage:'):
243
+ fails.append(f'{flag} did not print usage')
244
+
245
+ if fails:
246
+ print('selftest FAIL: ' + ' | '.join(fails), file=sys.stderr)
247
+ return 1
248
+ print(f'selftest OK — good fixture clean, bad fixture {len(bad)} findings, Domain Plan bad fixture {len(bad_plan)} findings, -h/--help contract holds')
249
+ return 0
250
+
251
+
252
+ _USAGE = 'usage: record-lint <file> | record-lint - | record-lint selftest'
253
+
254
+
255
+ def main(argv):
256
+ if len(argv) > 1 and argv[1] == 'selftest':
257
+ return _selftest()
258
+ # REQ-05 (gh#1136, factory-dx-ws5-consumer-verify): -h/--help must print
259
+ # usage and exit 0. Previously unhandled — `-h` fell through to the
260
+ # positional-file branch and crashed with an uncaught FileNotFoundError
261
+ # (exit 1, a traceback dumped to stderr), the gh#1122 class taken further.
262
+ if len(argv) > 1 and argv[1] in ('-h', '--help'):
263
+ print(_USAGE)
264
+ return 0
265
+ text = sys.stdin.read() if (len(argv) < 2 or argv[1] == '-') else open(argv[1], encoding='utf-8').read()
266
+ findings = lint(text)
267
+ if findings:
268
+ print(f'record-lint · {len(findings)} finding(s):')
269
+ for f in findings:
270
+ print(f' {f}')
271
+ print('Contract: app-planning SKILL.md §The Orientation Record.')
272
+ return 1
273
+ print('record-lint · clean')
274
+ return 0
275
+
276
+
277
+ if __name__ == '__main__':
278
+ sys.exit(main(sys.argv))
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
 
3
3
  /**
4
- * Builds the AdiaUI generation MCP server (30 tools: generation, discovery,
4
+ * Builds the AdiaUI generation MCP server (31 tools: generation, discovery,
5
5
  * retrieval, synthesis, validation, feedback/eval) with no transport
6
6
  * attached. Exported for tests and for `scripts/build/generate-mcp-tools-md.mjs`;
7
7
  * this file's own top-level `main()` call (unconditional, at module scope —
@@ -7,7 +7,7 @@ import {
7
7
  } from "@adia-ai/gen-ui/corpus/chunk-library";
8
8
  const SEARCH_CHUNKS_DESCRIPTION = `Search the gen-UI training-chunk corpus by keyword.
9
9
 
10
- The chunk corpus comes from \`packages/gen-ui/corpus/chunks/\` \u2014 JSON records
10
+ The chunk corpus comes from \`packages/gen-ui/engine/corpus/chunks/\` \u2014 JSON records
11
11
  extracted from every \`[data-chunk]\` element in site/pages/* and the corpus
12
12
  exemplars. There are three kinds:
13
13
  - block (default): atomic UI fragment (KPI grid, sign-in form, table)
@@ -4,7 +4,89 @@ import {
4
4
  getAllCompositions
5
5
  } from "@adia-ai/gen-ui/compose/strategies/zettel/composition-library";
6
6
  import { getChunk, getChunkIndex } from "@adia-ai/gen-ui/corpus/chunk-library";
7
+ let _tierIndex = null;
8
+ async function loadTierIndex() {
9
+ if (_tierIndex) return _tierIndex;
10
+ const mod = await import("@adia-ai/a2ui/catalog/tiers", { with: { type: "json" } });
11
+ _tierIndex = mod.default ?? mod;
12
+ return _tierIndex;
13
+ }
14
+ const GET_CATALOG_TIERS_DESCRIPTION = `Enumerate the AdiaUI catalog's tier ladder (ADR-0050): L0 primitives, L1 widgets, L2 layouts, L3 shells, L4 flows. Every tier-N entry is a declared composition of tier-(N-1) entries, so \`composes\` is the ladder edge you follow to expand an entry into the rung below.
15
+
16
+ Called with no arguments it returns the ladder summary \u2014 per tier: entry unit, which tier it composes, whether it is populated or still reserved, and the entry count. Pass \`tier\` to list that tier's entry names; pass \`tier\` + \`name\` for one entry's full record.
17
+
18
+ This is the CONTRACT view (what exists and how it composes), served from \`@adia-ai/a2ui/catalog\`. For per-component prop schemas use \`get_component_map\` / \`lookup_component\`; for corpus fuel (harvested chunks) use \`search_chunks\`.`;
7
19
  function registerDiscoveryTools(server) {
20
+ server.tool(
21
+ "get_catalog_tiers",
22
+ GET_CATALOG_TIERS_DESCRIPTION,
23
+ {
24
+ tier: z.enum(["L0", "L1", "L2", "L3", "L4"]).optional().describe("List one tier's entries instead of the ladder summary"),
25
+ name: z.string().optional().describe("With `tier`, return this single entry's full record")
26
+ },
27
+ async ({ tier, name }) => {
28
+ const index = await loadTierIndex();
29
+ if (!tier) {
30
+ const ladder = Object.fromEntries(
31
+ Object.entries(index.tiers).map(([id, t]) => [
32
+ id,
33
+ { unit: t.unit, composesTier: t.composesTier, status: t.status, count: t.count }
34
+ ])
35
+ );
36
+ return {
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: JSON.stringify({ catalogId: index.catalogId, ladder }, null, 2)
41
+ }
42
+ ]
43
+ };
44
+ }
45
+ const rung = index.tiers[tier];
46
+ if (!rung) {
47
+ return {
48
+ content: [{ type: "text", text: `Unknown tier "${tier}"` }],
49
+ isError: true
50
+ };
51
+ }
52
+ if (name) {
53
+ const entry = rung.entries[name];
54
+ if (!entry) {
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: `No ${tier} entry named "${name}". ${rung.count} entries in this tier.`
60
+ }
61
+ ],
62
+ isError: true
63
+ };
64
+ }
65
+ return {
66
+ content: [{ type: "text", text: JSON.stringify({ tier, name, entry }, null, 2) }]
67
+ };
68
+ }
69
+ return {
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: JSON.stringify(
74
+ {
75
+ tier,
76
+ unit: rung.unit,
77
+ composesTier: rung.composesTier,
78
+ status: rung.status,
79
+ count: rung.count,
80
+ entries: Object.keys(rung.entries).sort()
81
+ },
82
+ null,
83
+ 2
84
+ )
85
+ }
86
+ ]
87
+ };
88
+ }
89
+ );
8
90
  server.tool(
9
91
  "list_patterns",
10
92
  `List all composition patterns in the A2UI corpus. Optional filters narrow by domain (auth, settings, dashboard, etc.) or category (block, page, flow).`,
@@ -11,7 +11,7 @@ function registerFeedbackTools(server) {
11
11
  // TOOLS.md, which no MCP client could read. TOOLS.md is generated from here now.
12
12
  `Submit structured feedback for a generation execution. Used by the evolution engine to learn from each generation.
13
13
 
14
- Persists the rating to \`packages/gen-ui/corpus/feedback/<date>.jsonl\` through the shared \`submitFeedback\` path \u2014 the same one the gen-UI gallery's thumbs affordance posts to (gh#668). Optional \`engine\` / \`strategy\` / \`score\` / \`source\` context is carried onto the stored rating so human signal can rank weak domains.`,
14
+ Persists the rating to \`packages/gen-ui/engine/corpus/feedback/<date>.jsonl\` through the shared \`submitFeedback\` path \u2014 the same one the gen-UI gallery's thumbs affordance posts to (gh#668). Optional \`engine\` / \`strategy\` / \`score\` / \`source\` context is carried onto the stored rating so human signal can rank weak domains.`,
15
15
  {
16
16
  executionId: z.string().describe("Execution ID from generate_ui"),
17
17
  rating: z.number().min(1).max(5).describe("Overall quality 1-5 (>=4 counts as a thumbs-up)"),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adia-ai/mcp",
3
- "version": "0.8.37",
4
- "description": "AdiaUI's two MCP servers, one npm package (gh#1240). `adia-mcp gen-ui` — the 30-tool generation server (compose engine, corpus, retrieval, feedback/eval loop). `adia-mcp protocol` — the 4-tool A2UI protocol server (validate + registry introspection, no generation system, no model client). ADR-0048 §3's two-server decision is unchanged; only the distribution unified.",
3
+ "version": "0.8.39",
4
+ "description": "AdiaUI's three MCP servers, one npm package (gh#1240, ADR-0051). `adia-mcp gen-ui` — the 31-tool generation server (compose engine, corpus, retrieval, feedback/eval loop). `adia-mcp protocol` — the 5-tool A2UI protocol server (validate + registry introspection + the L0/L1 catalog-contract rungs, no generation system, no model client). `adia-mcp factory` — the 7-tool adia-factory server (orient, scaffold, audit, surface QA for building adia-ui apps from any MCP harness; no generation system, no model client). ADR-0048 §3's distinct-server decision is unchanged; only the distribution unified.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "adia-mcp": "./bin/adia-mcp"
@@ -16,6 +16,11 @@
16
16
  "types": "./protocol/server.d.ts",
17
17
  "import": "./protocol/server.js",
18
18
  "default": "./protocol/server.js"
19
+ },
20
+ "./factory/server.js": {
21
+ "types": "./factory/server.d.ts",
22
+ "import": "./factory/server.js",
23
+ "default": "./factory/server.js"
19
24
  }
20
25
  },
21
26
  "files": [
@@ -26,6 +31,11 @@
26
31
  "gen-ui/tools/",
27
32
  "protocol/server.js",
28
33
  "protocol/tools/",
34
+ "factory/server.js",
35
+ "factory/tools/",
36
+ "factory/vendor/",
37
+ "factory/resources/",
38
+ "factory/public-surface.json",
29
39
  "README.md",
30
40
  "TOOLS.md",
31
41
  "CHANGELOG.md",
@@ -41,7 +51,7 @@
41
51
  "repository": {
42
52
  "type": "git",
43
53
  "url": "git+https://github.com/adiahealth/gen-ui-kit.git",
44
- "directory": "packages/mcp"
54
+ "directory": "packages/gen-ui/mcp"
45
55
  },
46
56
  "dependencies": {
47
57
  "@adia-ai/a2ui": "^0.8.0",
@@ -1,8 +1,9 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
 
3
3
  /**
4
- * Builds the A2UI protocol MCP server (4 tools: validate_document,
5
- * get_registry_map, get_wiring_registry, protocol_status) with no transport
4
+ * Builds the A2UI protocol MCP server (5 tools: validate_document,
5
+ * get_registry_map, get_wiring_registry, get_catalog_ladder, protocol_status)
6
+ * with no transport
6
7
  * attached. Exported for tests and for `scripts/build/generate-mcp-tools-md.mjs`;
7
8
  * the module's own top-level `isEntryPoint()` guard is what actually starts a
8
9
  * stdio transport when this file is run directly (`node server.js`), not when
@@ -16,7 +16,7 @@ async function main() {
16
16
  const server = createServer();
17
17
  const transport = new StdioServerTransport();
18
18
  await server.connect(transport);
19
- console.error("[a2ui-protocol-mcp] stdio transport ready (4 protocol tools)");
19
+ console.error("[a2ui-protocol-mcp] stdio transport ready (5 protocol tools)");
20
20
  }
21
21
  function isEntryPoint() {
22
22
  if (typeof process === "undefined" || !process.argv[1]) return false;
@@ -10,6 +10,11 @@ This is the registry view \u2014 the authoritative answer to "what types exist a
10
10
  const GET_WIRING_REGISTRY_DESCRIPTION = `Get the A2UI wiring registry: controller types, action-handler names, and data-URI resolver schemes the runtime can resolve.
11
11
 
12
12
  Read live from the runtime's wiringRegistry, so it cannot drift from what the renderer will actually accept. The richer authoring knowledge base (UI event payloads, refresh strategies, value sources, association types) is generation-side \u2014 see gen-ui-mcp's \`get_wiring_catalog\` tool instead.`;
13
+ const GET_CATALOG_LADDER_DESCRIPTION = `Enumerate the AdiaUI catalog contract's served rungs: L0 primitives (component vocabulary) and L1 widgets (named, versioned, chat-scale functional units \u2014 a Row of stat cards, a sign-in card, a tool-call accordion). An L1 entry carries its A2UI \`template\`, so this is the tool to call before generating a document by hand: find the widget, take its template, fill in the copy.
14
+
15
+ Called with no arguments it returns the ladder summary \u2014 every rung with its entry unit, the rung it composes, populated/reserved status, and entry count. Pass \`tier\` ("L0" or "L1") for that rung's entry names; pass \`tier\` + \`name\` for one entry's full record (\`composes\`, \`template\`, \`keywords\`, \`domain\`, provenance).
16
+
17
+ Scope, per ADR-0050's serving map: this server serves **L0 + L1** \u2014 the rungs a chat agent enumerates to emit A2UI. L2 layouts, L3 shells, and L4 flows are developer-facing and belong to the factory surface; \`adia-mcp gen-ui\`'s \`get_catalog_tiers\` serves the whole ladder for retrieval. The ladder's one law is that every tier-N entry is a declared composition of tier-(N-1) entries, so \`composes\` is the edge you follow down a rung \u2014 an L1 entry's \`composes\` names L0 types you can validate with \`validate_document\`.`;
13
18
  const PROTOCOL_STATUS_DESCRIPTION = `Returns operational status of this A2UI protocol MCP server: transport and protocol-registry stats. Reports on the protocol server only \u2014 gen-ui-mcp's \`server_status\` tool reports its own corpus-side status separately.`;
14
19
  function buildRegistryView() {
15
20
  const byTag = /* @__PURE__ */ new Map();
@@ -25,6 +30,14 @@ function buildRegistryView() {
25
30
  })).sort((a, b) => a.type.localeCompare(b.type));
26
31
  return { totalTypes: registry.size, totalTags: byTag.size, entries };
27
32
  }
33
+ const SERVED_TIERS = ["L0", "L1"];
34
+ let _tierIndex = null;
35
+ async function loadTierIndex() {
36
+ if (_tierIndex) return _tierIndex;
37
+ const mod = await import("@adia-ai/a2ui/catalog/tiers", { with: { type: "json" } });
38
+ _tierIndex = mod.default ?? mod;
39
+ return _tierIndex;
40
+ }
28
41
  function buildWiringView() {
29
42
  return {
30
43
  controllers: [...wiringRegistry.controllers.keys()].sort(),
@@ -68,6 +81,116 @@ function registerProtocolTools(server) {
68
81
  return { content: [{ type: "text", text: JSON.stringify(buildWiringView(), null, 2) }] };
69
82
  }
70
83
  );
84
+ server.tool(
85
+ "get_catalog_ladder",
86
+ GET_CATALOG_LADDER_DESCRIPTION,
87
+ {
88
+ tier: z.enum(["L0", "L1"]).optional().describe("List one served rung's entry names instead of the ladder summary"),
89
+ name: z.string().optional().describe("With `tier` (required alongside it), return this single entry's full record")
90
+ },
91
+ async ({ tier, name }) => {
92
+ if (name && !tier) {
93
+ return {
94
+ content: [
95
+ {
96
+ type: "text",
97
+ text: `\`name\` needs a \`tier\`: entry names are unique only within a rung. Retry with { tier: "L1", name: "${name}" } (or "L0"), or omit \`name\` for the ladder summary.`
98
+ }
99
+ ],
100
+ isError: true
101
+ };
102
+ }
103
+ let index;
104
+ try {
105
+ index = await loadTierIndex();
106
+ } catch (err) {
107
+ const e = err instanceof Error ? err : new Error(String(err));
108
+ return {
109
+ content: [{ type: "text", text: `Catalog tier index unavailable: ${e.message}` }],
110
+ isError: true
111
+ };
112
+ }
113
+ if (!tier) {
114
+ const ladder = Object.fromEntries(
115
+ Object.entries(index.tiers).map(([id, t]) => [
116
+ id,
117
+ {
118
+ unit: t.unit,
119
+ composesTier: t.composesTier,
120
+ status: t.status,
121
+ count: t.count,
122
+ servedHere: SERVED_TIERS.includes(id)
123
+ }
124
+ ])
125
+ );
126
+ return {
127
+ content: [
128
+ {
129
+ type: "text",
130
+ text: JSON.stringify(
131
+ {
132
+ catalogId: index.catalogId,
133
+ servedTiers: [...SERVED_TIERS],
134
+ servingMap: "protocol server: L0+L1 (this tool) \xB7 gen-ui server: all tiers, for retrieval (get_catalog_tiers) \xB7 factory server: L2-L4, developer-facing",
135
+ ladder
136
+ },
137
+ null,
138
+ 2
139
+ )
140
+ }
141
+ ]
142
+ };
143
+ }
144
+ const rung = index.tiers[tier];
145
+ if (!rung) {
146
+ return {
147
+ content: [
148
+ {
149
+ type: "text",
150
+ text: `Tier "${tier}" is absent from the catalog tier index (${Object.keys(index.tiers).join(", ")} present) \u2014 the index may predate it.`
151
+ }
152
+ ],
153
+ isError: true
154
+ };
155
+ }
156
+ if (name) {
157
+ const entry = rung.entries[name];
158
+ if (!entry) {
159
+ return {
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: `No ${tier} entry named "${name}". ${rung.count} entries in this tier \u2014 call this tool with just \`tier\` for the names.`
164
+ }
165
+ ],
166
+ isError: true
167
+ };
168
+ }
169
+ return {
170
+ content: [{ type: "text", text: JSON.stringify({ tier, name, entry }, null, 2) }]
171
+ };
172
+ }
173
+ return {
174
+ content: [
175
+ {
176
+ type: "text",
177
+ text: JSON.stringify(
178
+ {
179
+ tier,
180
+ unit: rung.unit,
181
+ composesTier: rung.composesTier,
182
+ status: rung.status,
183
+ count: rung.count,
184
+ entries: Object.keys(rung.entries).sort()
185
+ },
186
+ null,
187
+ 2
188
+ )
189
+ }
190
+ ]
191
+ };
192
+ }
193
+ );
71
194
  server.tool(
72
195
  "protocol_status",
73
196
  PROTOCOL_STATUS_DESCRIPTION,