@a2uicatalog/mcp 0.1.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 +93 -0
- package/data/a2ui-state-v1.json +731 -0
- package/data/artemis-roadmap.json +341 -0
- package/data/atoms-index.json +4612 -0
- package/data/expenses-demo.json +140 -0
- package/data/spec.json +14644 -0
- package/demo.mjs +63 -0
- package/package.json +25 -0
- package/scripts/sync-data.mjs +47 -0
- package/server.mjs +235 -0
- package/test.mjs +70 -0
package/demo.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* demo.mjs — the GAS-first MCP loop, narrated end to end.
|
|
4
|
+
*
|
|
5
|
+
* Proves (and storyboards) what an agent does through the MCP server:
|
|
6
|
+
* browse vocabulary → author → validate → DEPLOY to a live Apps Script app.
|
|
7
|
+
*
|
|
8
|
+
* Run: BUILD_API_URL=… BUILD_API_TOKEN=… node demo.mjs
|
|
9
|
+
* (BYO: your own Apps Script deployment + token.)
|
|
10
|
+
*/
|
|
11
|
+
import { validate, buildApp } from "./server.mjs";
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
|
+
|
|
16
|
+
const DATA = join(dirname(fileURLToPath(import.meta.url)), "data");
|
|
17
|
+
const step = (n, t) => console.log(`\n\x1b[36m● ${n}\x1b[0m ${t}`);
|
|
18
|
+
|
|
19
|
+
step("1", "browse the vocabulary (list_atoms / get_atom_schema)");
|
|
20
|
+
const idx = JSON.parse(readFileSync(join(DATA, "atoms-index.json"), "utf8"));
|
|
21
|
+
const st = JSON.parse(readFileSync(join(DATA, "a2ui-state-v1.json"), "utf8"));
|
|
22
|
+
const primitiveCount = Object.keys(st.components || {}).length + Object.keys(st.functions || {}).length;
|
|
23
|
+
console.log(` ${idx.atomCount} atoms (a2ui-atoms-v1) + ${primitiveCount} state primitives (a2ui-state-v1)`);
|
|
24
|
+
console.log(` the agent speaks these — visual vocabulary + behavioral vocabulary`);
|
|
25
|
+
|
|
26
|
+
step("2", "author a wired surface (agent composes A2UI)");
|
|
27
|
+
const md = `---
|
|
28
|
+
id: mcp-demo-live
|
|
29
|
+
domain: training
|
|
30
|
+
name: "Built by an agent, over MCP"
|
|
31
|
+
source: "gas-first mcp launch demo"
|
|
32
|
+
license: MIT
|
|
33
|
+
---
|
|
34
|
+
An interactive Apps Script web app — authored by an agent, validated, deployed.
|
|
35
|
+
# Steps
|
|
36
|
+
## 1. Speak the vocabulary
|
|
37
|
+
cmd: list_atoms
|
|
38
|
+
verify: atoms returned
|
|
39
|
+
## 2. Validate before deploy
|
|
40
|
+
cmd: validate_payload
|
|
41
|
+
verify: hallucination = parse error, not broken UI
|
|
42
|
+
## 3. Ship it
|
|
43
|
+
cmd: build_app
|
|
44
|
+
verify: live Apps Script URL
|
|
45
|
+
`;
|
|
46
|
+
console.log(" composed a training.md (doc → app)");
|
|
47
|
+
|
|
48
|
+
step("3", "validate (parser catches mistakes — hallucination = parse error)");
|
|
49
|
+
const good = { type: "a2ui_wired_surface", title: "demo",
|
|
50
|
+
state_primitives: [{ id: "q", primitive: "ValueStore", props: {} }],
|
|
51
|
+
actions: [], layout: [{ id: "t", atom: "data_table", wire: { rows: "#q.value" } }] };
|
|
52
|
+
console.log(" valid wired surface →", JSON.stringify(validate(good).ok ? "OK" : validate(good).errors));
|
|
53
|
+
const bad = { ...good, layout: [{ id: "x", atom: "totally_made_up_atom" }] };
|
|
54
|
+
console.log(" hallucinated atom → REJECTED:", validate(bad).errors[0]);
|
|
55
|
+
|
|
56
|
+
step("4", "DEPLOY (build_app → live Apps Script web app)");
|
|
57
|
+
if (!process.env.BUILD_API_URL || !process.env.BUILD_API_TOKEN) {
|
|
58
|
+
console.log(" [skipped — set BUILD_API_URL + BUILD_API_TOKEN to deploy live]");
|
|
59
|
+
} else {
|
|
60
|
+
const r = await buildApp(md);
|
|
61
|
+
console.log(r.ok ? ` ✅ LIVE: ${r.url}` : ` build failed`);
|
|
62
|
+
}
|
|
63
|
+
console.log("\n\x1b[32mThat's the loop.\x1b[0m Author → validate → live GAS app, over MCP. No code.\n");
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@a2uicatalog/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local MCP server for the A2UI Atomic Catalog: browse the 474-atom vocabulary, validate a payload (hallucinated atom = parse error, not a broken screen), get a shareable preview URL, and deploy a training.md to your own Google Apps Script. Pure-local for browsing/validation — no network, no secret, no account.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "a2uicatalog-mcp": "server.mjs" },
|
|
7
|
+
"engines": { "node": ">=18" },
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "Curtis Krygier",
|
|
10
|
+
"homepage": "https://a2uicatalog.ai",
|
|
11
|
+
"repository": { "type": "git", "url": "git+https://github.com/a2uicatalog/a2ui.git", "directory": "mcp" },
|
|
12
|
+
"bugs": "https://github.com/a2uicatalog/a2ui/issues",
|
|
13
|
+
"keywords": ["mcp", "model-context-protocol", "a2ui", "ui-generation", "claude", "agent", "google-apps-script"],
|
|
14
|
+
"files": ["server.mjs", "demo.mjs", "test.mjs", "scripts", "data", "README.md"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node test.mjs",
|
|
17
|
+
"demo": "node demo.mjs",
|
|
18
|
+
"sync-data": "node scripts/sync-data.mjs",
|
|
19
|
+
"prepublishOnly": "npm run sync-data && npm test"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
23
|
+
"zod": "^3.23.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sync-data.mjs — refresh mcp/data/, the bundled catalog snapshot @a2uicatalog/mcp
|
|
4
|
+
* ships with.
|
|
5
|
+
*
|
|
6
|
+
* server.mjs used to read these files via a relative "../" path straight out
|
|
7
|
+
* of the a2ui-catalogue repo tree (public/atoms/index.json, public/spec.json,
|
|
8
|
+
* public/catalogue/a2ui-state-v1.json, payloads/*.json). That works from a
|
|
9
|
+
* git checkout but breaks the moment this package is actually published and
|
|
10
|
+
* installed standalone (`npm install @a2uicatalog/mcp`): only mcp/'s own files ship
|
|
11
|
+
* in the tarball, so "../public/..." would resolve to node_modules/@a2uicatalog/'s
|
|
12
|
+
* parent, not this repo — a crash on first run for every real installer,
|
|
13
|
+
* caught here (2026-07-31) before ever publishing. Fetching the same data
|
|
14
|
+
* live from a2uicatalog.ai at runtime was the other option; deliberately
|
|
15
|
+
* NOT taken, because list_atoms/get_atom_schema/validate_payload/encode_url
|
|
16
|
+
* are documented and designed as pure-local, no-network tools (see README's
|
|
17
|
+
* Security section) — bundling a point-in-time snapshot keeps that promise.
|
|
18
|
+
*
|
|
19
|
+
* Run after any atom-change / state-catalog change, and always before
|
|
20
|
+
* `npm publish` (also wired as this package's own prepublishOnly hook —
|
|
21
|
+
* belt + braces, matches the repo's own "declare it, don't improvise it"
|
|
22
|
+
* discipline: see ops/project-ops.yaml's mcp-sdk-sync-data process).
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
25
|
+
import { fileURLToPath } from "url";
|
|
26
|
+
import { dirname, join } from "path";
|
|
27
|
+
|
|
28
|
+
const MCP_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
const REPO_ROOT = join(MCP_ROOT, "..");
|
|
30
|
+
const DATA_DIR = join(MCP_ROOT, "data");
|
|
31
|
+
|
|
32
|
+
const FILES = [
|
|
33
|
+
["public/atoms/index.json", "atoms-index.json"],
|
|
34
|
+
["public/spec.json", "spec.json"],
|
|
35
|
+
["public/catalogue/a2ui-state-v1.json", "a2ui-state-v1.json"],
|
|
36
|
+
["payloads/expenses-demo.json", "expenses-demo.json"],
|
|
37
|
+
["payloads/artemis-roadmap.json", "artemis-roadmap.json"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
41
|
+
for (const [src, dest] of FILES) {
|
|
42
|
+
const content = readFileSync(join(REPO_ROOT, src), "utf8");
|
|
43
|
+
JSON.parse(content); // fail loudly on a malformed source rather than bundle garbage
|
|
44
|
+
writeFileSync(join(DATA_DIR, dest), content);
|
|
45
|
+
console.log(` ${src} -> mcp/data/${dest} (${(content.length / 1024).toFixed(0)} KB)`);
|
|
46
|
+
}
|
|
47
|
+
console.log(`synced ${FILES.length} files into mcp/data/`);
|
package/server.mjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* a2uicatalog-mcp — local MCP server for the A2UI Atomic Catalog.
|
|
4
|
+
*
|
|
5
|
+
* Implements the official A2UI-over-MCP pattern (a2ui.org/guides/a2ui_over_mcp):
|
|
6
|
+
* an agent browses the atom vocabulary, authors a wired surface, validates it
|
|
7
|
+
* (a hallucinated atom is a parse error, not a broken screen), and either gets
|
|
8
|
+
* a shareable preview URL or deploys straight to its own Google Apps Script.
|
|
9
|
+
*
|
|
10
|
+
* list_atoms / get_atom_schema / validate_payload / encode_url / get_ui are
|
|
11
|
+
* pure-local: bundled catalog data (mcp/data/, see scripts/sync-data.mjs),
|
|
12
|
+
* no secret, no network. build_app is the one tool that calls out — to the
|
|
13
|
+
* caller's OWN Apps Script deployment, with the caller's own token (BYO,
|
|
14
|
+
* never a shared secret; see README's Security section). stdio transport.
|
|
15
|
+
*/
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { readFileSync } from "fs";
|
|
20
|
+
import { fileURLToPath } from "url";
|
|
21
|
+
import { dirname, join } from "path";
|
|
22
|
+
import { gzipSync } from "zlib";
|
|
23
|
+
|
|
24
|
+
// Bundled inside THIS package (mcp/data/, synced by scripts/sync-data.mjs)
|
|
25
|
+
// rather than read via "../public/..." out of the wider a2ui-catalogue repo
|
|
26
|
+
// tree — that relative path only resolves in a git checkout of the whole
|
|
27
|
+
// monorepo, and breaks the moment this package is installed standalone
|
|
28
|
+
// (`npm install @a2uicatalog/mcp` ships only mcp/'s own files). See sync-data.mjs's
|
|
29
|
+
// header for why bundling a snapshot, not a live fetch, is the fix.
|
|
30
|
+
const DATA = join(dirname(fileURLToPath(import.meta.url)), "data");
|
|
31
|
+
const A2UI_MIME = "application/a2ui+json";
|
|
32
|
+
|
|
33
|
+
// ── bundled, public, read-only ──────────────────────────────────────────────
|
|
34
|
+
const index = JSON.parse(readFileSync(join(DATA, "atoms-index.json"), "utf8"));
|
|
35
|
+
const ATOMS = new Map(index.atoms.map((a) => [a.type, a]));
|
|
36
|
+
// Full field contracts (for get_atom_schema) from the published spec.
|
|
37
|
+
const SPEC = JSON.parse(readFileSync(join(DATA, "spec.json"), "utf8"));
|
|
38
|
+
const ATOM_SPEC = new Map(SPEC.atoms.map((a) => [a.type, a]));
|
|
39
|
+
for (const a of SPEC.atoms) for (const al of a.aliases || []) ATOM_SPEC.set(al, a);
|
|
40
|
+
// Resolve published aliases (data_table -> data_table_sortable) from the catalog.
|
|
41
|
+
for (const a of index.atoms) for (const al of a.aliases || []) ATOMS.set(al, a);
|
|
42
|
+
// The a2ui-state catalog: the behavioral vocabulary a wired surface also speaks.
|
|
43
|
+
// Migrated to the A2UI-v1.0 catalog shape (components/functions objects keyed
|
|
44
|
+
// by name) since this file was last touched — the old flat `primitives` array
|
|
45
|
+
// with `.id` fields no longer exists (confirmed broken here, 2026-07-31: this
|
|
46
|
+
// package's own test.mjs threw on startup). STATE_PRIMITIVES is the union of
|
|
47
|
+
// both — a wired surface's state_primitives can reference either a stateful
|
|
48
|
+
// component (ValueStore, ...) or a derived-value function (elapsed_seconds, ...).
|
|
49
|
+
const stateCatalog = JSON.parse(readFileSync(join(DATA, "a2ui-state-v1.json"), "utf8"));
|
|
50
|
+
const STATE_PRIMITIVE_DOCS = new Map([
|
|
51
|
+
...Object.entries(stateCatalog.components || {}),
|
|
52
|
+
...Object.entries(stateCatalog.functions || {}),
|
|
53
|
+
]);
|
|
54
|
+
const STATE_PRIMITIVES = new Set(STATE_PRIMITIVE_DOCS.keys());
|
|
55
|
+
// Reference wired UIs served over MCP (public payloads only).
|
|
56
|
+
const UIS = {
|
|
57
|
+
"expenses-demo": "expenses-demo.json",
|
|
58
|
+
"artemis-roadmap": "artemis-roadmap.json",
|
|
59
|
+
};
|
|
60
|
+
function loadUI(name) {
|
|
61
|
+
return JSON.parse(readFileSync(join(DATA, UIS[name]), "utf8"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Structural layout wrappers (not atoms). Atom aliases now resolve from the
|
|
65
|
+
// PUBLISHED catalog (ATOMS includes them), so the earlier harvest hack is gone —
|
|
66
|
+
// the validator references the real a2ui-atoms-v1 + a2ui-state-v1 catalogs.
|
|
67
|
+
const WIRED_ATOMS = new Set(["row_open", "row_close", "subheading", "body", "divider", "jump_nav"]);
|
|
68
|
+
// carry over any layout atoms used in reference UIs not yet promoted to schema
|
|
69
|
+
for (const name of Object.keys(UIS)) {
|
|
70
|
+
for (const el of loadUI(name).layout || []) {
|
|
71
|
+
const t = el.atom || el.type;
|
|
72
|
+
if (t && !ATOMS.has(t)) WIRED_ATOMS.add(t);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Structural validation: every layout atom's type must be a known atom, and a
|
|
77
|
+
// wired surface's wires must reference declared nodes. (No execution.)
|
|
78
|
+
function validate(payload) {
|
|
79
|
+
const errors = [];
|
|
80
|
+
const layout = payload.layout || [];
|
|
81
|
+
const nodeIds = new Set([
|
|
82
|
+
...(payload.state_primitives || []).map((p) => p.id),
|
|
83
|
+
...(payload.actions || []).map((a) => a.id),
|
|
84
|
+
...layout.map((el) => el.id).filter(Boolean),
|
|
85
|
+
]);
|
|
86
|
+
const known = (t) => ATOMS.has(t) || WIRED_ATOMS.has(t);
|
|
87
|
+
// state_primitives validate against the a2ui-state catalog (the behavioral half)
|
|
88
|
+
for (const p of payload.state_primitives || []) {
|
|
89
|
+
if (p.primitive && !STATE_PRIMITIVES.has(p.primitive)) {
|
|
90
|
+
errors.push(`unknown state primitive: ${p.primitive} (id ${p.id || "?"})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const el of layout) {
|
|
94
|
+
const t = el.atom || el.type;
|
|
95
|
+
if (t && !known(t)) errors.push(`unknown atom type: ${t} (id ${el.id || "?"})`);
|
|
96
|
+
for (const expr of Object.values(el.wire || {})) {
|
|
97
|
+
const m = /^#(\w+)\./.exec(String(expr));
|
|
98
|
+
if (m && !nodeIds.has(m[1])) errors.push(`wire references undeclared node #${m[1]} (id ${el.id || "?"})`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const wired = payload.type === "a2ui_wired_surface";
|
|
102
|
+
return {
|
|
103
|
+
ok: errors.length === 0,
|
|
104
|
+
wired,
|
|
105
|
+
counts: { atoms: layout.length, state: (payload.state_primitives || []).length, actions: (payload.actions || []).length },
|
|
106
|
+
errors,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function encodeUrl(payload, base = "<public_exec>") {
|
|
111
|
+
const enc = gzipSync(Buffer.from(JSON.stringify(payload))).toString("base64url");
|
|
112
|
+
return `${base}/exec?p=${enc}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const MAX_INPUT = 256 * 1024; // 256KB cap on agent-supplied payloads/docs
|
|
116
|
+
// Never let a token surface in any response/error text (query-param token leak guard).
|
|
117
|
+
function redact(text) {
|
|
118
|
+
let t = String(text);
|
|
119
|
+
if (process.env.BUILD_API_TOKEN) t = t.split(process.env.BUILD_API_TOKEN).join("***");
|
|
120
|
+
return t.replace(/([?&]token=)[^&\s"']+/gi, "$1***");
|
|
121
|
+
}
|
|
122
|
+
function tooBig(s) { return typeof s === "string" && s.length > MAX_INPUT; }
|
|
123
|
+
|
|
124
|
+
const server = new McpServer({ name: "a2uicatalog-mcp", version: "0.1.0" });
|
|
125
|
+
|
|
126
|
+
// ── OFFICIAL SERVING PATTERN: A2UI as MCP Resource (application/a2ui+json) ──
|
|
127
|
+
for (const name of Object.keys(UIS)) {
|
|
128
|
+
server.registerResource(
|
|
129
|
+
name,
|
|
130
|
+
`a2ui://${name}`,
|
|
131
|
+
{ title: `A2UI: ${name}`, description: `Wired A2UI surface (${name})`, mimeType: A2UI_MIME },
|
|
132
|
+
async (uri) => ({
|
|
133
|
+
contents: [{ uri: uri.href, mimeType: A2UI_MIME, text: JSON.stringify(loadUI(name)) }],
|
|
134
|
+
})
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── OFFICIAL SERVING PATTERN: dynamic UI via Tool -> EmbeddedResource ──
|
|
139
|
+
server.registerTool(
|
|
140
|
+
"get_ui",
|
|
141
|
+
{ title: "Get A2UI surface", description: "Return a wired A2UI surface as an EmbeddedResource (application/a2ui+json).",
|
|
142
|
+
inputSchema: { name: z.enum(Object.keys(UIS)) } },
|
|
143
|
+
async ({ name }) => {
|
|
144
|
+
const payload = loadUI(name);
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "resource", resource: { uri: `a2ui://${name}`, mimeType: A2UI_MIME, text: JSON.stringify(payload) } }],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// ── AUTHORING TOOLS: vocabulary + validation (the wired-authoring surface) ──
|
|
152
|
+
server.registerTool(
|
|
153
|
+
"list_atoms",
|
|
154
|
+
{ title: "List A2UI atoms", description: "Browse the atom vocabulary (compact descriptions).",
|
|
155
|
+
inputSchema: { query: z.string().optional() } },
|
|
156
|
+
async ({ query }) => {
|
|
157
|
+
let atoms = index.atoms;
|
|
158
|
+
if (query) atoms = atoms.filter((a) => (a.type + " " + a.compact).toLowerCase().includes(query.toLowerCase()));
|
|
159
|
+
return { content: [{ type: "text", text: JSON.stringify(atoms.slice(0, 40).map((a) => ({ type: a.type, compact: a.compact })), null, 1) }] };
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
"get_atom_schema",
|
|
165
|
+
{ title: "Get an atom's field contract",
|
|
166
|
+
description: "Full field contract + surfaces for one atom (or state primitive), from the published catalogs.",
|
|
167
|
+
inputSchema: { type: z.string().describe("atom type or state primitive id") } },
|
|
168
|
+
async ({ type }) => {
|
|
169
|
+
const atom = ATOM_SPEC.get(type);
|
|
170
|
+
if (atom) return { content: [{ type: "text", text: JSON.stringify(atom, null, 1) }] };
|
|
171
|
+
const prim = STATE_PRIMITIVE_DOCS.get(type);
|
|
172
|
+
if (prim) return { content: [{ type: "text", text: JSON.stringify({ id: type, ...prim, catalog: "a2ui-state-v1" }, null, 1) }] };
|
|
173
|
+
return { isError: true, content: [{ type: "text", text: `unknown type: ${type} — not in a2ui-atoms-v1 or a2ui-state-v1` }] };
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
server.registerTool(
|
|
178
|
+
"validate_payload",
|
|
179
|
+
{ title: "Validate an A2UI payload", description: "Structural validation of an authored A2UI payload (atom types + wire references). No execution.",
|
|
180
|
+
inputSchema: { payload: z.any() } },
|
|
181
|
+
async ({ payload }) => {
|
|
182
|
+
if (tooBig(JSON.stringify(payload))) return { isError: true, content: [{ type: "text", text: `payload too large (max ${MAX_INPUT} bytes)` }] };
|
|
183
|
+
return { content: [{ type: "text", text: JSON.stringify(validate(payload), null, 1) }] };
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
server.registerTool(
|
|
188
|
+
"encode_url",
|
|
189
|
+
{ title: "Encode preview URL", description: "gzip+base64url an A2UI payload into a renderer ?p= URL.",
|
|
190
|
+
inputSchema: { payload: z.any() } },
|
|
191
|
+
async ({ payload }) => ({ content: [{ type: "text", text: encodeUrl(payload) }] })
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// ── THE KILLER TOOL: build_app — author -> Build API -> LIVE Apps Script URL ──
|
|
195
|
+
// BYO-local: BUILD_API_URL + BUILD_API_TOKEN come from the dev's own env; this
|
|
196
|
+
// deploys to the DEV's own Apps Script, never a shared surface. Token never in code.
|
|
197
|
+
server.registerTool(
|
|
198
|
+
"build_app",
|
|
199
|
+
{ title: "Build a live Apps Script web app",
|
|
200
|
+
description: "Deploy a training.md document to YOUR Apps Script via the A2UI Build API — returns a live GAS web app URL. Requires env BUILD_API_URL and BUILD_API_TOKEN (BYO, local).",
|
|
201
|
+
inputSchema: { training_md: z.string().describe("A compliant training.md document") } },
|
|
202
|
+
async ({ training_md }) => {
|
|
203
|
+
const url = process.env.BUILD_API_URL, token = process.env.BUILD_API_TOKEN;
|
|
204
|
+
if (!url || !token) {
|
|
205
|
+
return { isError: true, content: [{ type: "text", text: "Set BUILD_API_URL and BUILD_API_TOKEN in your environment (BYO Apps Script). The build tool never carries a token in code." }] };
|
|
206
|
+
}
|
|
207
|
+
if (tooBig(training_md)) return { isError: true, content: [{ type: "text", text: `training_md too large (max ${MAX_INPUT} bytes)` }] };
|
|
208
|
+
try {
|
|
209
|
+
const res = await fetch(`${url}?api=training&token=${encodeURIComponent(token)}`, {
|
|
210
|
+
method: "POST", headers: { "Content-Type": "text/plain" }, body: training_md,
|
|
211
|
+
});
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
if (!body.ok) return { isError: true, content: [{ type: "text", text: redact("Build failed: " + (body.error || JSON.stringify(body))) }] };
|
|
214
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, url: body.url, coverage: body.coverage, steps: body.steps, warnings: body.warnings }, null, 1) }] };
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return { isError: true, content: [{ type: "text", text: redact("build_app error: " + (e.message || String(e))) }] };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
export async function buildApp(training_md) {
|
|
222
|
+
const url = process.env.BUILD_API_URL, token = process.env.BUILD_API_TOKEN;
|
|
223
|
+
const res = await fetch(`${url}?api=training&token=${encodeURIComponent(token)}`,
|
|
224
|
+
{ method: "POST", headers: { "Content-Type": "text/plain" }, body: training_md });
|
|
225
|
+
return res.json();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Export internals for the test harness (when imported, not run over stdio).
|
|
229
|
+
export { validate, encodeUrl, loadUI, ATOMS };
|
|
230
|
+
|
|
231
|
+
if (process.argv[1] && process.argv[1].endsWith("server.mjs")) {
|
|
232
|
+
const transport = new StdioServerTransport();
|
|
233
|
+
await server.connect(transport);
|
|
234
|
+
console.error("a2uicatalog-mcp test harness on stdio");
|
|
235
|
+
}
|
package/test.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { validate, encodeUrl, loadUI, ATOMS } from './server.mjs';
|
|
2
|
+
import { gunzipSync } from 'zlib';
|
|
3
|
+
|
|
4
|
+
let fails = 0;
|
|
5
|
+
const ok = (c, m) => { console.log((c ? '✅' : '❌') + ' ' + m); if (!c) fails++; };
|
|
6
|
+
|
|
7
|
+
console.log('=== TEST 1: does WIRED state survive the official MCP carry? ===');
|
|
8
|
+
// Serve a wired payload the way get_ui does: wrap as EmbeddedResource(application/a2ui+json)
|
|
9
|
+
const original = loadUI('expenses-demo');
|
|
10
|
+
const embedded = { type: 'resource', resource: { uri: 'a2ui://expenses-demo', mimeType: 'application/a2ui+json', text: JSON.stringify(original) } };
|
|
11
|
+
// Client side: unwrap and re-parse
|
|
12
|
+
const carried = JSON.parse(embedded.resource.text);
|
|
13
|
+
ok(embedded.resource.mimeType === 'application/a2ui+json', 'carried with the A2UI content type');
|
|
14
|
+
ok(carried.type === 'a2ui_wired_surface', 'payload is a WIRED surface after carry');
|
|
15
|
+
ok(carried.state_primitives.length === original.state_primitives.length && carried.state_primitives.length === 6, 'state_primitives intact through EmbeddedResource (6)');
|
|
16
|
+
ok(carried.actions.length === 2, 'actions intact (2)');
|
|
17
|
+
const wiresIn = original.layout.filter(el => el.wire).length;
|
|
18
|
+
const wiresOut = carried.layout.filter(el => el.wire).length;
|
|
19
|
+
ok(wiresIn === wiresOut && wiresIn > 0, `wire graph intact (${wiresOut} wired atoms)`);
|
|
20
|
+
ok(JSON.stringify(carried) === JSON.stringify(original), 'byte-identical: MCP carried the full wired substrate, lost nothing');
|
|
21
|
+
|
|
22
|
+
console.log('\n=== TEST 2: can an agent AUTHOR a valid wired surface from the vocabulary? ===');
|
|
23
|
+
// Act as the agent: compose a NEW wired surface using atoms + a ValueStore + an ArrayFilter,
|
|
24
|
+
// wiring a text input -> filter -> table. (The catalogue-as-vocabulary authoring path.)
|
|
25
|
+
const authored = {
|
|
26
|
+
type: 'a2ui_wired_surface',
|
|
27
|
+
title: 'Authored: filterable list',
|
|
28
|
+
state_primitives: [
|
|
29
|
+
{ id: 'q', primitive: 'ValueStore', props: { defaultValue: '' } },
|
|
30
|
+
{ id: 'flt', primitive: 'ArrayFilter', props: { source: '#load.result', field: 'name', match: '#q.value' } }
|
|
31
|
+
],
|
|
32
|
+
actions: [ { id: 'load', type: 'gas:store_read', trigger: 'onLoad', props: { agent_id: 'demo', store: 'items' } } ],
|
|
33
|
+
layout: [
|
|
34
|
+
{ id: 'h', atom: 'subheading', props: { text: 'Items' } },
|
|
35
|
+
{ id: 'search', atom: 'form_input', props: { label: 'Filter' }, wire: { onChange: '#q.setValue' } },
|
|
36
|
+
{ id: 'tbl', atom: 'data_table', props: { columns: ['name', 'qty'] }, wire: { rows: '#flt.result' } }
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
const v = validate(authored);
|
|
40
|
+
ok(v.wired, 'authored payload is a wired surface');
|
|
41
|
+
ok(v.ok, 'authored wired surface VALIDATES: ' + (v.ok ? 'clean' : JSON.stringify(v.errors)));
|
|
42
|
+
ok(v.counts.state === 2 && v.counts.actions === 1, `agent wired 2 state primitives + 1 action`);
|
|
43
|
+
|
|
44
|
+
console.log('\n=== TEST 2b: does the validator CATCH a hallucinated atom / bad wire? ===');
|
|
45
|
+
const bad = JSON.parse(JSON.stringify(authored));
|
|
46
|
+
bad.layout.push({ id: 'x', atom: 'quantum_flux_capacitor', props: {} }); // hallucinated atom
|
|
47
|
+
bad.layout.push({ id: 'y', atom: 'body', wire: { text: '#nonexistent.value' } }); // dangling wire
|
|
48
|
+
const vb = validate(bad);
|
|
49
|
+
ok(!vb.ok, 'invalid payload REJECTED (hallucination = parse error, not broken UI)');
|
|
50
|
+
ok(vb.errors.some(e => e.includes('quantum_flux_capacitor')), 'caught the hallucinated atom by name');
|
|
51
|
+
ok(vb.errors.some(e => e.includes('#nonexistent')), 'caught the dangling wire reference');
|
|
52
|
+
|
|
53
|
+
console.log('\nencoded preview URL sample:', encodeUrl(authored).slice(0, 70) + '…');
|
|
54
|
+
|
|
55
|
+
console.log('\n=== TEST 3: catalogs closed — validator resolves alias + state primitive from PUBLISHED artifacts ===');
|
|
56
|
+
import { validate as v3 } from './server.mjs';
|
|
57
|
+
// data_table (alias) + ArrayFilter/ValueStore (state catalog) — all from published catalogs now
|
|
58
|
+
const both = {
|
|
59
|
+
type: 'a2ui_wired_surface', title: 'both catalogs',
|
|
60
|
+
state_primitives: [ { id: 'q', primitive: 'ValueStore', props: {} }, { id: 'f', primitive: 'ArrayFilter', props: { source: '#a.result', field: 'x', match: '#q.value' } } ],
|
|
61
|
+
actions: [ { id: 'a', type: 'gas:store_read', props: {} } ],
|
|
62
|
+
layout: [ { id: 't', atom: 'data_table', wire: { rows: '#f.result' } } ] // data_table = published alias
|
|
63
|
+
};
|
|
64
|
+
const rb = v3(both);
|
|
65
|
+
ok3(rb.ok, 'data_table (alias) + ValueStore/ArrayFilter (state catalog) all validate from PUBLISHED catalogs: ' + (rb.ok ? 'clean' : JSON.stringify(rb.errors)));
|
|
66
|
+
const badState = { ...both, state_primitives: [ { id: 'z', primitive: 'QuantumStore', props: {} } ] };
|
|
67
|
+
const rz = v3(badState);
|
|
68
|
+
ok3(!rz.ok && rz.errors.some(e => e.includes('QuantumStore')), 'unknown state primitive REJECTED by name (state catalog enforced)');
|
|
69
|
+
function ok3(c, m){ console.log((c?'✅':'❌')+' '+m); if(!c) process.exitCode = 1; }
|
|
70
|
+
process.exit(fails || process.exitCode || 0);
|