@plitzi/sdk-mcp 0.38.7 → 0.38.9
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/CHANGELOG.md +204 -0
- package/README.md +9 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -3
- package/dist/modules/mcp/helpers/guide.js +37 -21
- package/dist/modules/mcp/index.d.ts +2 -2
- package/dist/modules/mcp/server.d.ts +4 -4
- package/dist/modules/mcp/server.js +10 -5
- package/dist/modules/mcp/tests/cheapMistakes.test.d.ts +1 -0
- package/dist/modules/mcp/tests/helpers.d.ts +9 -2
- package/dist/modules/mcp/tests/intents.test.d.ts +1 -0
- package/dist/modules/mcp/tests/look.test.d.ts +1 -0
- package/dist/modules/mcp/tools/apply/index.d.ts +64 -50
- package/dist/modules/mcp/tools/apply/index.js +70 -20
- package/dist/modules/mcp/tools/describeOperation.d.ts +11 -0
- package/dist/modules/mcp/tools/describeOperation.js +40 -0
- package/dist/modules/mcp/tools/describeOperation.test.d.ts +1 -0
- package/dist/modules/mcp/tools/index.d.ts +1 -6
- package/dist/modules/mcp/tools/index.js +12 -9
- package/dist/modules/mcp/tools/intents.d.ts +5 -0
- package/dist/modules/mcp/tools/intents.js +201 -0
- package/dist/modules/mcp/tools/look.d.ts +17 -0
- package/dist/modules/mcp/tools/look.js +32 -0
- package/dist/modules/mcp/tools/operations/closed.d.ts +7 -0
- package/dist/modules/mcp/tools/operations/closed.js +29 -0
- package/dist/modules/mcp/tools/operations/index.d.ts +229 -180
- package/dist/modules/mcp/tools/operations/index.js +72 -51
- package/dist/modules/mcp/tools/operations/schema/bindings/upsertBinding.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/schema/interactions/upsertInteractionFlow.d.ts +1 -1
- package/dist/modules/mcp/tools/operations/schema/operations.d.ts +2 -2
- package/dist/modules/mcp/tools/operations/schema/shared.d.ts +2 -2
- package/dist/modules/mcp/tools/operations/schema/shared.js +7 -6
- package/dist/modules/mcp/tools/operations/schemaIds.js +4 -4
- package/dist/modules/mcp/tools/render.d.ts +40 -40
- package/dist/modules/mcp/tools/render.js +5 -1
- package/dist/modules/mcp/tools/shared/draftBatch.d.ts +3 -2
- package/dist/modules/mcp/tools/shared/draftBatch.js +16 -5
- package/dist/modules/mcp/tools/shared/effects.d.ts +2 -0
- package/dist/modules/mcp/tools/shared/effects.js +155 -0
- package/dist/modules/mcp/tools/shared/effects.test.d.ts +1 -0
- package/dist/modules/mcp/tools/shared/look.d.ts +32 -0
- package/dist/modules/mcp/tools/shared/look.js +128 -0
- package/dist/modules/mcp/tools/shared/normalizeOperations.d.ts +6 -0
- package/dist/modules/mcp/tools/shared/normalizeOperations.js +37 -0
- package/dist/modules/mcp/tools/shared/repeats.d.ts +13 -0
- package/dist/modules/mcp/tools/shared/repeats.js +38 -0
- package/dist/modules/mcp/tools/shared/tool.d.ts +9 -6
- package/dist/modules/mcp/tools/shared/tool.js +2 -1
- package/dist/modules/mcp/tools/shared/validator/context.d.ts +0 -1
- package/dist/modules/mcp/tools/shared/validator/elements.js +0 -1
- package/dist/modules/mcp/tools/shared/validator/index.js +25 -2
- package/dist/modules/mcp/types/screenshotTypes.d.ts +3 -3
- package/dist/modules/mcp/types/toolTypes.d.ts +2 -4
- package/dist/options.d.ts +4 -5
- package/dist/preview/createPreview.js +9 -18
- package/package.json +7 -7
- package/skills/plitzi-render/SKILL.md +8 -184
- package/skills/plitzi-render/reference/good-call.md +88 -0
- package/skills/plitzi-render/reference/interactive.md +64 -0
- package/skills/plitzi-render/reference/looks.md +29 -0
- package/dist/modules/mcp/tools/preview.d.ts +0 -1423
- package/dist/modules/mcp/tools/preview.js +0 -37
- package/dist/modules/mcp/tools/screenshot.d.ts +0 -1708
- package/dist/modules/mcp/tools/screenshot.js +0 -127
- package/dist/modules/mcp/tools/validate.d.ts +0 -1699
- package/dist/modules/mcp/tools/validate.js +0 -32
|
@@ -2,14 +2,16 @@ import { cloneSpace } from "../../helpers/space.js";
|
|
|
2
2
|
import { expandOperations } from "./expandOperations.js";
|
|
3
3
|
import { fixTouched, lintDraft } from "./lintDraft.js";
|
|
4
4
|
import { newSuggestions } from "./newSuggestions.js";
|
|
5
|
+
import { normalizeOperations } from "./normalizeOperations.js";
|
|
5
6
|
import { validateOperations } from "./validator/index.js";
|
|
6
7
|
import { applyOperations } from "../apply/dispatch.js";
|
|
7
8
|
//#region src/modules/mcp/tools/shared/draftBatch.ts
|
|
8
9
|
/**
|
|
9
10
|
* A batch run on a copy of the space and read back — the one way every tool that takes operations handles them, so
|
|
10
|
-
* `
|
|
11
|
+
* a `dryRun` answers exactly what `plitzi_apply` would save, and `plitzi_render` holds a widget to the same rules.
|
|
11
12
|
*
|
|
12
|
-
* In order: the sugar ops expanded; what
|
|
13
|
+
* In order: the sugar ops expanded; what has one reading read that way (`normalizeOperations`, said in `warnings`);
|
|
14
|
+
* what was already wrong with the elements the batch touches fixed where it has one
|
|
13
15
|
* reading (`fixTouched`, each fix said in `warnings`); each op checked against the input it takes; the batch applied to
|
|
14
16
|
* the copy; and the copy read by `lintDraft` — structure and meaning, the same reading every other writer gets. The
|
|
15
17
|
* first stage to refuse ends it; the space handed in is never touched.
|
|
@@ -21,23 +23,32 @@ var draftBatch = (space, env, operations, mode = "space") => {
|
|
|
21
23
|
errors: expansion.errors,
|
|
22
24
|
warnings: []
|
|
23
25
|
};
|
|
24
|
-
const ops = expansion.operations;
|
|
26
|
+
const { operations: ops, notes } = normalizeOperations(space, expansion.operations);
|
|
25
27
|
const prepared = fixTouched(space, ops);
|
|
26
28
|
const validation = validateOperations(prepared.space, ops, mode);
|
|
27
29
|
if (!validation.valid) return {
|
|
28
30
|
ok: false,
|
|
29
31
|
errors: validation.errors,
|
|
30
|
-
warnings: [
|
|
32
|
+
warnings: [
|
|
33
|
+
...notes,
|
|
34
|
+
...prepared.fixed,
|
|
35
|
+
...validation.warnings
|
|
36
|
+
]
|
|
31
37
|
};
|
|
32
38
|
const draft = cloneSpace(prepared.space);
|
|
33
39
|
const outcome = applyOperations(draft, env, ops);
|
|
34
40
|
if (outcome.errors.length > 0) return {
|
|
35
41
|
ok: false,
|
|
36
42
|
errors: outcome.errors,
|
|
37
|
-
warnings: [
|
|
43
|
+
warnings: [
|
|
44
|
+
...notes,
|
|
45
|
+
...prepared.fixed,
|
|
46
|
+
...validation.warnings
|
|
47
|
+
]
|
|
38
48
|
};
|
|
39
49
|
const reading = lintDraft(draft, ops, prepared.space);
|
|
40
50
|
const warnings = [
|
|
51
|
+
...notes,
|
|
41
52
|
...prepared.fixed,
|
|
42
53
|
...validation.warnings,
|
|
43
54
|
...reading.warnings
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { canonicalJson } from "@plitzi/sdk-shared/helpers/canonicalJson";
|
|
2
|
+
import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
|
|
3
|
+
//#region src/modules/mcp/tools/shared/effects.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a write did to the space, read off the documents before and after it — never off what was asked. An agent
|
|
6
|
+
* that is told only what it asked for cannot see the element a shared class also restyled, the child a move took with
|
|
7
|
+
* it, or the operation that changed nothing; every one of those is a line here. Past `LIMIT` lines the rest is
|
|
8
|
+
* counted, never dropped unsaid.
|
|
9
|
+
*/
|
|
10
|
+
var LIMIT = 40;
|
|
11
|
+
var SHOWN = 60;
|
|
12
|
+
var same = (before, after) => canonicalJson(before) === canonicalJson(after);
|
|
13
|
+
var shown = (value) => {
|
|
14
|
+
const text = canonicalJson(value);
|
|
15
|
+
return text.length > SHOWN ? `${text.slice(0, 59)}…` : text;
|
|
16
|
+
};
|
|
17
|
+
/** Each entry of a list by what names it — its `id`, or its `name` — when every entry has one. */
|
|
18
|
+
var keyedList = (list) => {
|
|
19
|
+
const keyed = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const entry of list) {
|
|
21
|
+
const key = isRecord(entry) ? entry.id ?? entry.name : void 0;
|
|
22
|
+
if (typeof key !== "string" || keyed.has(key)) return;
|
|
23
|
+
keyed.set(key, entry);
|
|
24
|
+
}
|
|
25
|
+
return keyed;
|
|
26
|
+
};
|
|
27
|
+
var isScalar = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
28
|
+
/** Every difference between two values, at its path: a key added, removed or changed, an entry of a named list. */
|
|
29
|
+
var differences = (path, before, after, out) => {
|
|
30
|
+
if (same(before, after)) return;
|
|
31
|
+
if (before === void 0) {
|
|
32
|
+
out.push(`${path} = ${shown(after)}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (after === void 0) {
|
|
36
|
+
out.push(`${path} removed (was ${shown(before)})`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
40
|
+
const was = keyedList(before);
|
|
41
|
+
const is = keyedList(after);
|
|
42
|
+
if (was && is) {
|
|
43
|
+
for (const key of /* @__PURE__ */ new Set([...was.keys(), ...is.keys()])) differences(`${path}[${key}]`, was.get(key), is.get(key), out);
|
|
44
|
+
if ([...was.keys()].join("\n") !== [...is.keys()].join("\n") && same([...was.keys()].toSorted(), [...is.keys()].toSorted())) out.push(`${path} reordered`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (before.every(isScalar) && after.every(isScalar)) {
|
|
48
|
+
const added = after.filter((item) => !before.includes(item));
|
|
49
|
+
const removed = before.filter((item) => !after.includes(item));
|
|
50
|
+
if (added.length > 0 || removed.length > 0) out.push(`${path}: ${[...added.map((item) => `+${shown(item)}`), ...removed.map((item) => `−${shown(item)}`)].join(" ")}`);
|
|
51
|
+
else out.push(`${path}: ${shown(before)} → ${shown(after)}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (isRecord(before) && isRecord(after)) {
|
|
56
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) differences(`${path}.${key}`, before[key], after[key], out);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
out.push(`${path}: ${shown(before)} → ${shown(after)}`);
|
|
60
|
+
};
|
|
61
|
+
/** A key of a record, or nothing when it has none — said as such, where indexing would claim it is there. */
|
|
62
|
+
var lookup = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
63
|
+
/** An object without the keys a caller reads apart. */
|
|
64
|
+
var without = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
|
|
65
|
+
var classesOf = (selector) => (selector ?? "").split(/\s+/).filter(Boolean);
|
|
66
|
+
/** An element's own changes: its attributes, its classes, where it is, and the rest of its definition. */
|
|
67
|
+
var elementChanges = (id, before, after, out) => {
|
|
68
|
+
const bound = new Map((after.definition.bindings?.attributes ?? []).map((binding) => [binding.to, binding.source]));
|
|
69
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(before.attributes), ...Object.keys(after.attributes)])) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
differences(`${id}.${key}`, before.attributes[key], after.attributes[key], lines);
|
|
72
|
+
const source = bound.get(key);
|
|
73
|
+
out.push(...source ? lines.map((line) => `${line} — bound to ${source}: the page shows the binding's value`) : lines);
|
|
74
|
+
}
|
|
75
|
+
const { parentId: wasIn, items: hadItems, styleSelectors: wore } = before.definition;
|
|
76
|
+
const { parentId: isIn, items: hasItems, styleSelectors: wears } = after.definition;
|
|
77
|
+
if (wasIn !== isIn) out.push(`${id} moved from ${wasIn ?? "the top"} to ${isIn ?? "the top"}`);
|
|
78
|
+
if (!same(hadItems, hasItems) && same([...hadItems ?? []].toSorted(), [...hasItems ?? []].toSorted())) out.push(`${id}'s children reordered`);
|
|
79
|
+
for (const selector of /* @__PURE__ */ new Set([...Object.keys(wore), ...Object.keys(wears)])) {
|
|
80
|
+
const was = classesOf(wore[selector]);
|
|
81
|
+
const is = classesOf(wears[selector]);
|
|
82
|
+
const added = is.filter((name) => !was.includes(name));
|
|
83
|
+
const removed = was.filter((name) => !is.includes(name));
|
|
84
|
+
if (added.length > 0 || removed.length > 0) {
|
|
85
|
+
const changes = [...added.map((name) => `+${name}`), ...removed.map((name) => `−${name}`)].join(" ");
|
|
86
|
+
out.push(`${id} classes${selector === "base" ? "" : ` (${selector})`}: ${changes}`);
|
|
87
|
+
} else if (was.join(" ") !== is.join(" ")) out.push(`${id} classes${selector === "base" ? "" : ` (${selector})`} reordered`);
|
|
88
|
+
}
|
|
89
|
+
const apart = [
|
|
90
|
+
"parentId",
|
|
91
|
+
"items",
|
|
92
|
+
"styleSelectors"
|
|
93
|
+
];
|
|
94
|
+
differences(id, without(before.definition, apart), without(after.definition, apart), out);
|
|
95
|
+
};
|
|
96
|
+
/** The elements added to a tree, or taken from it, said by the outermost of each subtree with how many it held. */
|
|
97
|
+
var subtrees = (ids, flat, verb, where, out) => {
|
|
98
|
+
const top = (id) => {
|
|
99
|
+
const parentId = flat[id].definition.parentId;
|
|
100
|
+
return parentId !== void 0 && ids.has(parentId) ? top(parentId) : id;
|
|
101
|
+
};
|
|
102
|
+
const held = /* @__PURE__ */ new Map();
|
|
103
|
+
for (const id of ids) {
|
|
104
|
+
const root = top(id);
|
|
105
|
+
held.set(root, (held.get(root) ?? 0) + (root === id ? 0 : 1));
|
|
106
|
+
}
|
|
107
|
+
for (const [id, inside] of held) {
|
|
108
|
+
const { type, parentId } = flat[id].definition;
|
|
109
|
+
out.push(`${id} (${type}) ${verb}${parentId ? ` ${verb === "added" ? "in" : "from"} ${parentId}` : ""}${where}${inside > 0 ? `, with ${String(inside)} inside` : ""}`);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var treeChanges = (before, after, where, out) => {
|
|
113
|
+
const added = new Set(Object.keys(after).filter((id) => !(id in before)));
|
|
114
|
+
const removed = new Set(Object.keys(before).filter((id) => !(id in after)));
|
|
115
|
+
subtrees(added, after, "added", where, out);
|
|
116
|
+
subtrees(removed, before, "removed", where, out);
|
|
117
|
+
for (const id of Object.keys(after)) {
|
|
118
|
+
const was = lookup(before, id);
|
|
119
|
+
if (was) elementChanges(id, was, after[id], out);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var effectsOf = (before, after) => {
|
|
123
|
+
const out = [];
|
|
124
|
+
treeChanges(before.schema.flat, after.schema.flat, "", out);
|
|
125
|
+
const components = /* @__PURE__ */ new Set([...Object.keys(before.schema.components), ...Object.keys(after.schema.components)]);
|
|
126
|
+
for (const id of components) {
|
|
127
|
+
const was = lookup(before.schema.components, id);
|
|
128
|
+
const is = lookup(after.schema.components, id);
|
|
129
|
+
if (!was || !is) {
|
|
130
|
+
const held = Object.keys((was ?? is)?.flat ?? {}).length;
|
|
131
|
+
out.push(`component ${id} ${was ? "removed" : "added"}, ${String(held)} elements`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
treeChanges(was.flat, is.flat, ` (component ${id})`, out);
|
|
135
|
+
differences(`component ${id}`, without(was, ["flat"]), without(is, ["flat"]), out);
|
|
136
|
+
}
|
|
137
|
+
differences("space", without(before.schema, [
|
|
138
|
+
"flat",
|
|
139
|
+
"components",
|
|
140
|
+
"pages"
|
|
141
|
+
]), without(after.schema, [
|
|
142
|
+
"flat",
|
|
143
|
+
"components",
|
|
144
|
+
"pages"
|
|
145
|
+
]), out);
|
|
146
|
+
differences("space.pages", before.schema.pages.filter((id) => after.schema.pages.includes(id)), after.schema.pages.filter((id) => before.schema.pages.includes(id)), out);
|
|
147
|
+
differences("style", without(before.style, ["cache"]), without(after.style, ["cache"]), out);
|
|
148
|
+
differences("connectors", before.connectors, after.connectors, out);
|
|
149
|
+
differences("actions", before.actions, after.actions, out);
|
|
150
|
+
differences("functions", before.functions, after.functions, out);
|
|
151
|
+
differences("data", before.data, after.data, out);
|
|
152
|
+
return out.length > LIMIT ? [...out.slice(0, LIMIT), `… and ${String(out.length - LIMIT)} more changes: read the resources listed in \`changed\``] : out;
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
export { effectsOf };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ScreenshotImage } from '../../types';
|
|
3
|
+
import { Operation } from '../operations';
|
|
4
|
+
import { ToolContext } from './tool';
|
|
5
|
+
type LookViewport = 'desktop' | 'mobile' | 'both';
|
|
6
|
+
type LookView = 'html' | 'image' | 'accessibility' | 'both';
|
|
7
|
+
export declare const pageRef: z.ZodOptional<z.ZodString>;
|
|
8
|
+
export declare const viewport: z.ZodOptional<z.ZodEnum<{
|
|
9
|
+
desktop: "desktop";
|
|
10
|
+
mobile: "mobile";
|
|
11
|
+
both: "both";
|
|
12
|
+
}>>;
|
|
13
|
+
export declare const fullPage: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
type LookRequest = {
|
|
15
|
+
pageRef?: string;
|
|
16
|
+
/** A batch to render without saving it; left out, what is saved. */
|
|
17
|
+
operations?: Operation[];
|
|
18
|
+
view: LookView;
|
|
19
|
+
viewport?: LookViewport;
|
|
20
|
+
fullPage?: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** What a look found: the facts about the page, and the pictures when one was asked for and could be taken. */
|
|
23
|
+
type Look = {
|
|
24
|
+
meta: Record<string, unknown>;
|
|
25
|
+
images: ScreenshotImage[];
|
|
26
|
+
};
|
|
27
|
+
/** A look that could not happen at all — no renderer, or a batch the renderer refused — said as the tools say it. */
|
|
28
|
+
type LookRefusal = {
|
|
29
|
+
refused: Record<string, unknown>;
|
|
30
|
+
};
|
|
31
|
+
export declare const lookAt: (ctx: ToolContext, request: LookRequest) => Promise<Look | LookRefusal>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { unnamedControls } from "../../accessibilityOutline.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/modules/mcp/tools/shared/look.ts
|
|
4
|
+
/**
|
|
5
|
+
* How a page looks — as HTML, as a picture, or as a screen reader and a browser agent read it — rendered from the saved
|
|
6
|
+
* space or from a batch that was never saved.
|
|
7
|
+
*
|
|
8
|
+
* One path for every tool that shows a page: `plitzi_apply`'s `dryRun` (the batch it just checked, so the agent writes
|
|
9
|
+
* its operations once instead of once to check, once to look and once to save), and `plitzi_look` (what is saved).
|
|
10
|
+
*/
|
|
11
|
+
var VIEWPORTS = {
|
|
12
|
+
desktop: {
|
|
13
|
+
label: "desktop",
|
|
14
|
+
width: 1440,
|
|
15
|
+
height: 900
|
|
16
|
+
},
|
|
17
|
+
mobile: {
|
|
18
|
+
label: "mobile",
|
|
19
|
+
width: 390,
|
|
20
|
+
height: 844
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var resolveViewports = (choice) => {
|
|
24
|
+
if (choice === "both") return [VIEWPORTS.desktop, VIEWPORTS.mobile];
|
|
25
|
+
if (choice === "mobile") return [VIEWPORTS.mobile];
|
|
26
|
+
return [VIEWPORTS.desktop];
|
|
27
|
+
};
|
|
28
|
+
var CAPTURES = {
|
|
29
|
+
image: ["image"],
|
|
30
|
+
accessibility: ["accessibility"],
|
|
31
|
+
both: ["image", "accessibility"]
|
|
32
|
+
};
|
|
33
|
+
var pageRef = z.string().optional().describe("The page, by name; defaults to the space default page");
|
|
34
|
+
var viewport = z.enum([
|
|
35
|
+
"desktop",
|
|
36
|
+
"mobile",
|
|
37
|
+
"both"
|
|
38
|
+
]).optional().describe("Which viewport(s) to capture. \"both\" catches responsive issues (e.g. overflow at one width). Default desktop.");
|
|
39
|
+
var fullPage = z.boolean().optional().describe("Capture the full scrollable page instead of only the visible viewport. Default false.");
|
|
40
|
+
/** Each viewport's outline, with what in it has no name — the part an agent acts on. */
|
|
41
|
+
var readingOf = (outlines) => outlines.map(({ label, outline }) => ({
|
|
42
|
+
viewport: label,
|
|
43
|
+
outline,
|
|
44
|
+
unnamed: unnamedControls(outline)
|
|
45
|
+
}));
|
|
46
|
+
var ACCESSIBILITY_HINT = "Every entry in `unnamed` is a control or picture announced by its role alone (\"button\") — nobody using a screen reader, and no browser agent, can tell what it does. Name it — a button: `title` or words inside; a link: `label`; a field: `label` (with `hideLabel` to keep it out of sight); an image: `alt`, or `decorative: true`; an icon that means something alone: `label`. `line` is where it sits in the outline.";
|
|
47
|
+
var lookAt = async (ctx, request) => {
|
|
48
|
+
if (!ctx.preview || ctx.spaceId === void 0) return { refused: {
|
|
49
|
+
error: "PREVIEW_UNAVAILABLE",
|
|
50
|
+
message: "Visual preview is not enabled on this server.",
|
|
51
|
+
hint: "Preview needs the SSR render service; it is unavailable in MCP-only mode."
|
|
52
|
+
} };
|
|
53
|
+
const draft = {
|
|
54
|
+
spaceId: ctx.spaceId,
|
|
55
|
+
env: ctx.env,
|
|
56
|
+
pageRef: request.pageRef,
|
|
57
|
+
operations: request.operations
|
|
58
|
+
};
|
|
59
|
+
const page = request.pageRef ?? "default";
|
|
60
|
+
const wantsHtml = request.view === "html" || !ctx.screenshot;
|
|
61
|
+
const rendered = await ctx.preview.render({
|
|
62
|
+
...draft,
|
|
63
|
+
includeHtml: wantsHtml
|
|
64
|
+
});
|
|
65
|
+
if (!rendered.ok) return { refused: rendered };
|
|
66
|
+
const facts = {
|
|
67
|
+
pageRef: page,
|
|
68
|
+
pagePath: rendered.pagePath,
|
|
69
|
+
stateVersion: rendered.stateVersion
|
|
70
|
+
};
|
|
71
|
+
if (request.view === "html") return {
|
|
72
|
+
meta: {
|
|
73
|
+
...facts,
|
|
74
|
+
html: rendered.html
|
|
75
|
+
},
|
|
76
|
+
images: []
|
|
77
|
+
};
|
|
78
|
+
if (!ctx.screenshot) return {
|
|
79
|
+
meta: {
|
|
80
|
+
...facts,
|
|
81
|
+
warning: "SCREENSHOT_DISABLED",
|
|
82
|
+
message: request.view === "image" ? "The screenshot service is not configured; returning the HTML preview instead." : "No browser is configured, so the accessibility tree cannot be read; returning the HTML preview instead. Its roles and names are in the markup: every button, link and field needs words, every image an `alt`.",
|
|
83
|
+
html: rendered.html
|
|
84
|
+
},
|
|
85
|
+
images: []
|
|
86
|
+
};
|
|
87
|
+
const viewports = resolveViewports(request.viewport);
|
|
88
|
+
const shot = await ctx.screenshot.capture({
|
|
89
|
+
pagePath: rendered.pagePath,
|
|
90
|
+
token: rendered.token,
|
|
91
|
+
viewports,
|
|
92
|
+
fullPage: request.fullPage,
|
|
93
|
+
views: CAPTURES[request.view]
|
|
94
|
+
});
|
|
95
|
+
if (!shot.ok) {
|
|
96
|
+
const fallback = await ctx.preview.render(draft);
|
|
97
|
+
return {
|
|
98
|
+
meta: {
|
|
99
|
+
...facts,
|
|
100
|
+
warning: "SCREENSHOT_UNAVAILABLE",
|
|
101
|
+
message: `The screenshot service failed (${shot.message}); returning the HTML preview instead.`,
|
|
102
|
+
hint: "The dedicated browser service is down or unreachable. Inspect the HTML, or retry later.",
|
|
103
|
+
html: fallback.ok ? fallback.html : ""
|
|
104
|
+
},
|
|
105
|
+
images: []
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const meta = {
|
|
109
|
+
...facts,
|
|
110
|
+
viewports: viewports.map((v) => v.label)
|
|
111
|
+
};
|
|
112
|
+
const accessibility = request.view === "image" ? {} : shot.accessibility ? {
|
|
113
|
+
accessibility: readingOf(shot.accessibility),
|
|
114
|
+
hint: ACCESSIBILITY_HINT
|
|
115
|
+
} : {
|
|
116
|
+
warning: "ACCESSIBILITY_UNSUPPORTED",
|
|
117
|
+
message: "The browser service answered without the accessibility tree — it predates the accessibility view. The image, if asked for, is still here."
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
meta: {
|
|
121
|
+
...meta,
|
|
122
|
+
...accessibility
|
|
123
|
+
},
|
|
124
|
+
images: request.view === "accessibility" ? [] : shot.images
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
128
|
+
export { fullPage, lookAt, pageRef, viewport };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { buildTypeRegistry } from "../../catalogs/registry.js";
|
|
2
|
+
import { elementTypeNames } from "../../catalogs/index.js";
|
|
3
|
+
//#region src/modules/mcp/tools/shared/normalizeOperations.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a batch wrote that has one reading, read that way and said: an element type spelt with other capitals
|
|
6
|
+
* (`Heading`) is the type the catalog spells (`heading`). A guess between two names is never made here — a name with a
|
|
7
|
+
* typo is refused, or warned of, with the nearest one, by the checks that come after.
|
|
8
|
+
*/
|
|
9
|
+
/** The element types this space renders: the SDK's own and every one its catalog or its elements bring. */
|
|
10
|
+
var knownTypes = (space) => /* @__PURE__ */ new Set([...Object.keys(buildTypeRegistry(space.schema, space.catalog).types), ...elementTypeNames]);
|
|
11
|
+
var normalizedTree = (element, path, byLowerCase, known, notes) => {
|
|
12
|
+
const spelt = known.has(element.type) ? void 0 : byLowerCase.get(element.type.toLowerCase());
|
|
13
|
+
if (spelt) notes.push(`Read element type "${element.type}" as "${spelt}" (${path}.type): write it as the catalog spells it.`);
|
|
14
|
+
const children = element.children?.map((child, index) => normalizedTree(child, `${path}.children[${String(index)}]`, byLowerCase, known, notes));
|
|
15
|
+
return {
|
|
16
|
+
...element,
|
|
17
|
+
...spelt ? { type: spelt } : {},
|
|
18
|
+
...children ? { children } : {}
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
var normalizeOperations = (space, operations) => {
|
|
22
|
+
const known = knownTypes(space);
|
|
23
|
+
const spellings = /* @__PURE__ */ new Map();
|
|
24
|
+
for (const type of known) spellings.set(type.toLowerCase(), [...spellings.get(type.toLowerCase()) ?? [], type]);
|
|
25
|
+
const byLowerCase = new Map([...spellings].flatMap(([lower, types]) => types.length === 1 ? [[lower, types[0]]] : []));
|
|
26
|
+
const notes = [];
|
|
27
|
+
const normalized = operations.map((operation, index) => operation.type === "upsertElement" ? {
|
|
28
|
+
...operation,
|
|
29
|
+
element: normalizedTree(operation.element, `operations[${String(index)}].element`, byLowerCase, known, notes)
|
|
30
|
+
} : operation);
|
|
31
|
+
return {
|
|
32
|
+
operations: notes.length > 0 ? normalized : operations,
|
|
33
|
+
notes
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { normalizeOperations };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** One batch, by where it was sent and what it holds. */
|
|
2
|
+
export declare const batchKey: (scope: string, operations: unknown) => string;
|
|
3
|
+
/** How many times this batch was refused lately — before this one. */
|
|
4
|
+
export declare const timesRefused: (key: string, now?: number) => number;
|
|
5
|
+
/** The batch was refused once more. */
|
|
6
|
+
export declare const noteRefused: (key: string, now?: number) => void;
|
|
7
|
+
/** The batch went through: what was refused before no longer counts. */
|
|
8
|
+
export declare const forgetRefused: (key: string) => void;
|
|
9
|
+
export declare const AGAIN: string;
|
|
10
|
+
export declare const REPEATED: {
|
|
11
|
+
error: string;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/modules/mcp/tools/shared/repeats.ts
|
|
3
|
+
/**
|
|
4
|
+
* The same batch refused again — an agent going round in circles, which a model is not trusted to notice. Counted per
|
|
5
|
+
* process, as a hint and never a lock: a call that lands on another worker only loses the hint.
|
|
6
|
+
*/
|
|
7
|
+
var WINDOW_MS = 6e5;
|
|
8
|
+
var KEPT = 500;
|
|
9
|
+
var refused = /* @__PURE__ */ new Map();
|
|
10
|
+
/** One batch, by where it was sent and what it holds. */
|
|
11
|
+
var batchKey = (scope, operations) => `${scope}:${createHash("sha256").update(JSON.stringify(operations)).digest("hex")}`;
|
|
12
|
+
/** How many times this batch was refused lately — before this one. */
|
|
13
|
+
var timesRefused = (key, now = Date.now()) => {
|
|
14
|
+
const entry = refused.get(key);
|
|
15
|
+
return entry && now - entry.at < WINDOW_MS ? entry.count : 0;
|
|
16
|
+
};
|
|
17
|
+
/** The batch was refused once more. */
|
|
18
|
+
var noteRefused = (key, now = Date.now()) => {
|
|
19
|
+
refused.set(key, {
|
|
20
|
+
count: timesRefused(key, now) + 1,
|
|
21
|
+
at: now
|
|
22
|
+
});
|
|
23
|
+
if (refused.size > KEPT) {
|
|
24
|
+
const oldest = refused.keys().next().value;
|
|
25
|
+
if (oldest !== void 0) refused.delete(oldest);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** The batch went through: what was refused before no longer counts. */
|
|
29
|
+
var forgetRefused = (key) => {
|
|
30
|
+
refused.delete(key);
|
|
31
|
+
};
|
|
32
|
+
var AGAIN = "This batch was refused before, for these reasons. Sending it again will not change the answer: change what the errors name — plitzi_describe_operation { type } gives an operation’s fields, a hint its way out — or stop and ask the person what they meant.";
|
|
33
|
+
var REPEATED = {
|
|
34
|
+
error: "REPEATED_BATCH",
|
|
35
|
+
message: "This exact batch was refused twice, and was not run again. Change what the errors named, or ask the person."
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { AGAIN, REPEATED, batchKey, forgetRefused, noteRefused, timesRefused };
|
|
@@ -24,9 +24,6 @@ export interface ToolContext {
|
|
|
24
24
|
* wired it. Only plitzi_try_function uses it. */
|
|
25
25
|
tryFunction?: (task: string, params: Record<string, unknown>) => Promise<ActionRunReport>;
|
|
26
26
|
}
|
|
27
|
-
/** A capability a tool depends on; the host skips registering a tool whose capability it did not wire (so
|
|
28
|
-
* plitzi_screenshot simply does not appear when no browser service is configured). */
|
|
29
|
-
export type ToolRequires = 'screenshot';
|
|
30
27
|
/** Build the MCP CallToolResult for an image-returning tool: a text meta block followed by one image block per
|
|
31
28
|
* PNG, so a vision-capable agent (or MCP client) sees the render directly. */
|
|
32
29
|
export declare const imageResult: (images: ScreenshotImage[], meta: unknown) => CallToolResult;
|
|
@@ -40,8 +37,13 @@ export interface ToolSpec<Shape extends ZodRawShape> {
|
|
|
40
37
|
description: string;
|
|
41
38
|
inputShape: Shape;
|
|
42
39
|
access: 'read' | 'write';
|
|
43
|
-
/**
|
|
44
|
-
|
|
40
|
+
/** This tool's input carries the whole operations vocabulary (`plitzi_apply`): the one copy of it a connection lists. */
|
|
41
|
+
carriesOperations?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* What is LISTED for this tool when another tool on the connection carries the vocabulary: the same input with the
|
|
44
|
+
* operations' fields left out. Its calls are still parsed against `inputShape`, so nothing is checked less.
|
|
45
|
+
*/
|
|
46
|
+
compactInputShape?: ZodRawShape;
|
|
45
47
|
/** The tool operates on no space (plitzi_render authors a throwaway one), so the host must NOT resolve a spaceId
|
|
46
48
|
* or load a space for it — that keeps it callable with no auth, on the public surface. */
|
|
47
49
|
spaceless?: boolean;
|
|
@@ -63,7 +65,8 @@ export interface ToolDef {
|
|
|
63
65
|
description: string;
|
|
64
66
|
inputShape: ZodRawShape;
|
|
65
67
|
access: 'read' | 'write';
|
|
66
|
-
|
|
68
|
+
carriesOperations?: boolean;
|
|
69
|
+
compactInputShape?: ZodRawShape;
|
|
67
70
|
spaceless?: boolean;
|
|
68
71
|
ui?: McpUiToolMeta;
|
|
69
72
|
execute: (args: unknown, ctx: ToolContext) => unknown;
|
|
@@ -22,7 +22,8 @@ var defineTool = (spec) => {
|
|
|
22
22
|
description: spec.description,
|
|
23
23
|
inputShape: spec.inputShape,
|
|
24
24
|
access: spec.access,
|
|
25
|
-
|
|
25
|
+
carriesOperations: spec.carriesOperations,
|
|
26
|
+
compactInputShape: spec.compactInputShape,
|
|
26
27
|
spaceless: spec.spaceless,
|
|
27
28
|
ui: spec.ui,
|
|
28
29
|
execute: (args, ctx) => spec.run(parse(args), ctx),
|
|
@@ -21,7 +21,6 @@ export interface ValidationCtx {
|
|
|
21
21
|
errors: ValidationError[];
|
|
22
22
|
warnings: string[];
|
|
23
23
|
warned: Set<string>;
|
|
24
|
-
knownTypes: Set<string>;
|
|
25
24
|
typeProps: Map<string, Set<string>>;
|
|
26
25
|
typeMeta: Map<string, TypeMeta>;
|
|
27
26
|
elementType: (ref: string) => string | undefined;
|
|
@@ -48,7 +48,6 @@ var checkElementInput = (element, path, ctx, seen) => {
|
|
|
48
48
|
message: "Element type is required",
|
|
49
49
|
hint: "Read plitzi://types for known types"
|
|
50
50
|
});
|
|
51
|
-
else if (!ctx.knownTypes.has(element.type)) ctx.warnings.push(`Type "${element.type}" was not seen in this space; ensure a plugin provides it (${path}.type).`);
|
|
52
51
|
checkElementProps(element, path, ctx);
|
|
53
52
|
element.children?.forEach((child, i) => checkElementInput(child, `${path}.children[${i}]`, ctx, seen));
|
|
54
53
|
};
|
|
@@ -2,7 +2,6 @@ import { componentView, findComponentByRef } from "../../../helpers/components.j
|
|
|
2
2
|
import { findElementByRef, findFolderByRef, findPageByRef, findRootByRef, folderAncestorIds, getLayoutElements, getPageElements, pageFoldersOf, resolveRef, routeParamNames } from "../../../helpers/space.js";
|
|
3
3
|
import { observedDataSources, observedInteractionActions } from "../../../catalogs/observed.js";
|
|
4
4
|
import { buildTypeRegistry } from "../../../catalogs/registry.js";
|
|
5
|
-
import { elementTypeNames } from "../../../catalogs/index.js";
|
|
6
5
|
import { MAX_OPS } from "../../operations/index.js";
|
|
7
6
|
import { batchDeclaredElements, batchDeclaredFolders, batchDeclaredPages, batchDeclaredVariants, batchDeclaredVars } from "./batch.js";
|
|
8
7
|
import { checkObservedName, warnOnce } from "./context.js";
|
|
@@ -13,6 +12,7 @@ import { checkRef } from "./refs.js";
|
|
|
13
12
|
import { checkElementInput, checkRawMarkup, checkTypeProps, checkVariantApplication } from "./elements.js";
|
|
14
13
|
import { checkInteractionNode } from "./interactions.js";
|
|
15
14
|
import { treeOf } from "@plitzi/sdk-schema/helpers/components";
|
|
15
|
+
import { closest } from "@plitzi/sdk-authoring";
|
|
16
16
|
import { isValidElementId } from "@plitzi/sdk-schema/helpers/elementId";
|
|
17
17
|
//#region src/modules/mcp/tools/shared/validator/index.ts
|
|
18
18
|
var STYLE_CATEGORIES = [
|
|
@@ -49,7 +49,6 @@ var buildValidationCtx = (space, ops, mode = "space") => {
|
|
|
49
49
|
errors: [],
|
|
50
50
|
warnings: [],
|
|
51
51
|
warned: /* @__PURE__ */ new Set(),
|
|
52
|
-
knownTypes: /* @__PURE__ */ new Set([...Object.keys(registry.types), ...elementTypeNames]),
|
|
53
52
|
typeProps: new Map(Object.entries(registry.types).map(([type, info]) => [type, new Set(Object.keys(info.props))])),
|
|
54
53
|
typeMeta: buildTypeMeta(space.catalog),
|
|
55
54
|
elementType: (ref) => treeOf(space.schema, ref)?.flat[ref]?.definition.type,
|
|
@@ -76,6 +75,28 @@ var validateOperations = (space, ops, mode = "space") => {
|
|
|
76
75
|
if (input.type === PROVIDER_TYPE) checkProviderElement(space, ctx, input.ref, input.runtime, input.props, batchConnectors, base);
|
|
77
76
|
(input.children ?? []).forEach((child, i) => checkProviders(child, `${base}.children[${i}]`));
|
|
78
77
|
};
|
|
78
|
+
const classes = /* @__PURE__ */ new Set([...Object.values(space.style.platform).flatMap((breakpoint) => Object.entries(breakpoint).filter(([, item]) => item.type === "class").map(([name]) => name)), ...ops.flatMap((op) => op.type === "upsertDefinition" ? [op.ref] : op.type === "upsertDefinitions" ? Object.keys(op.definitions) : [])]);
|
|
79
|
+
const checkClasses = (style, path) => {
|
|
80
|
+
const worn = [...(style?.base ?? []).map((name) => ({
|
|
81
|
+
name,
|
|
82
|
+
at: `${path}.base`
|
|
83
|
+
})), ...Object.entries(style?.slots ?? {}).flatMap(([slot, names]) => names.map((name) => ({
|
|
84
|
+
name,
|
|
85
|
+
at: `${path}.slots.${slot}`
|
|
86
|
+
})))];
|
|
87
|
+
for (const { name, at } of worn) if (!classes.has(name)) {
|
|
88
|
+
const nearest = closest(name, classes);
|
|
89
|
+
ctx.errors.push({
|
|
90
|
+
path: at,
|
|
91
|
+
message: `"${name}" is not a class of the space${nearest ? ` — did you mean "${nearest}"?` : ""}`,
|
|
92
|
+
hint: "Name one of plitzi://definitions/{env}, or declare it earlier in the batch with upsertDefinition"
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const checkTreeClasses = (input, path) => {
|
|
97
|
+
checkClasses(input.style, `${path}.style`);
|
|
98
|
+
(input.children ?? []).forEach((child, i) => checkTreeClasses(child, `${path}.children[${i}]`));
|
|
99
|
+
};
|
|
79
100
|
if (ops.length > 1e3) ctx.errors.push({
|
|
80
101
|
path: "operations",
|
|
81
102
|
message: `Batch has ${ops.length} operations (max ${MAX_OPS})`,
|
|
@@ -103,11 +124,13 @@ var validateOperations = (space, ops, mode = "space") => {
|
|
|
103
124
|
switch (op.type) {
|
|
104
125
|
case "upsertElement":
|
|
105
126
|
checkElementInput(op.element, `${base}.element`, ctx, /* @__PURE__ */ new Set());
|
|
127
|
+
checkTreeClasses(op.element, `${base}.element`);
|
|
106
128
|
checkVariantApplication(op.element.initialState, `${base}.element.initialState`, ctx);
|
|
107
129
|
checkProviders(op.element, `${base}.element`);
|
|
108
130
|
break;
|
|
109
131
|
case "patchElement": {
|
|
110
132
|
checkRef(op.ref, `${base}.ref`, ctx);
|
|
133
|
+
checkClasses(op.style, `${base}.style`);
|
|
111
134
|
const page = findRootByRef(scoped, component ? component.rootId : op.pageRef);
|
|
112
135
|
const target = page ? resolveRef(scoped, page, op.ref) : void 0;
|
|
113
136
|
if (op.props && target && target.id !== page?.id) {
|
|
@@ -44,9 +44,9 @@ export type ScreenshotInput = {
|
|
|
44
44
|
*/
|
|
45
45
|
views?: CaptureView[];
|
|
46
46
|
};
|
|
47
|
-
/** How
|
|
48
|
-
* client to the dedicated browser pod); absent
|
|
49
|
-
*
|
|
47
|
+
/** How a look reaches the headless-browser service. The consumer injects an implementation (an HTTP
|
|
48
|
+
* client to the dedicated browser pod); absent, or a capture that fails (pod down) and answers `ok:false` rather than
|
|
49
|
+
* throwing, and the look answers the HTML instead. */
|
|
50
50
|
export type ScreenshotClient = {
|
|
51
51
|
capture: (input: ScreenshotInput) => Promise<ScreenshotResult>;
|
|
52
52
|
};
|
|
@@ -44,6 +44,8 @@ export interface WriteResponse {
|
|
|
44
44
|
updated: number;
|
|
45
45
|
deleted: number;
|
|
46
46
|
};
|
|
47
|
+
/** What the batch did, read off the space before and after it: a line per change, nothing left unsaid. */
|
|
48
|
+
effects?: string[];
|
|
47
49
|
changed: ChangedResource[];
|
|
48
50
|
elements?: WriteElement[];
|
|
49
51
|
warnings?: string[];
|
|
@@ -83,10 +85,6 @@ export interface ApplyInput {
|
|
|
83
85
|
expectedResourceVersions?: Record<string, string>;
|
|
84
86
|
operations: Operation[];
|
|
85
87
|
}
|
|
86
|
-
export interface ValidateInput {
|
|
87
|
-
environment?: string;
|
|
88
|
-
operations: Operation[];
|
|
89
|
-
}
|
|
90
88
|
export interface ValidationResult {
|
|
91
89
|
valid: boolean;
|
|
92
90
|
errors: ValidationError[];
|