@json-layout/core 2.8.2 → 2.9.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/package.json +4 -2
- package/src/compile/index.js +3 -1
- package/src/compile/skeleton-node.js +46 -5
- package/src/compile/types.ts +1 -0
- package/src/compile/utils/resolve-refs.js +5 -8
- package/src/compile/utils/x-i18n.js +15 -6
- package/src/state/index.js +18 -1
- package/src/state/state-node.js +11 -2
- package/src/state/utils/urls.js +2 -0
- package/src/utils/json-pointer.js +29 -0
- package/src/webmcp/README.md +144 -0
- package/src/webmcp/index.js +88 -82
- package/src/webmcp/project.js +479 -111
- package/src/webmcp/resolve.js +37 -1
- package/src/webmcp/schema.js +169 -0
- package/src/webmcp/suggestions-store.js +121 -0
- package/src/webmcp/tools/describe-state.js +20 -64
- package/src/webmcp/tools/edit-array.js +51 -28
- package/src/webmcp/tools/fill-form-skill.js +17 -41
- package/src/webmcp/tools/get-data.js +66 -13
- package/src/webmcp/tools/get-field-suggestions.js +12 -23
- package/src/webmcp/tools/set-data.js +79 -28
- package/src/webmcp/tools/set-field-value.js +49 -39
- package/src/webmcp/variants-memo.js +53 -0
- package/types/compile/index.d.ts.map +1 -1
- package/types/compile/skeleton-node.d.ts +9 -2
- package/types/compile/skeleton-node.d.ts.map +1 -1
- package/types/compile/types.d.ts +1 -0
- package/types/compile/types.d.ts.map +1 -1
- package/types/compile/utils/resolve-refs.d.ts.map +1 -1
- package/types/compile/utils/x-i18n.d.ts +1 -1
- package/types/compile/utils/x-i18n.d.ts.map +1 -1
- package/types/state/index.d.ts +10 -0
- package/types/state/index.d.ts.map +1 -1
- package/types/state/state-node.d.ts.map +1 -1
- package/types/state/utils/urls.d.ts.map +1 -1
- package/types/utils/json-pointer.d.ts +22 -0
- package/types/utils/json-pointer.d.ts.map +1 -0
- package/types/webmcp/index.d.ts +23 -15
- package/types/webmcp/index.d.ts.map +1 -1
- package/types/webmcp/project.d.ts +159 -57
- package/types/webmcp/project.d.ts.map +1 -1
- package/types/webmcp/resolve.d.ts +7 -3
- package/types/webmcp/resolve.d.ts.map +1 -1
- package/types/webmcp/schema.d.ts +44 -0
- package/types/webmcp/schema.d.ts.map +1 -0
- package/types/webmcp/suggestions-store.d.ts +82 -0
- package/types/webmcp/suggestions-store.d.ts.map +1 -0
- package/types/webmcp/tools/describe-state.d.ts +5 -57
- package/types/webmcp/tools/describe-state.d.ts.map +1 -1
- package/types/webmcp/tools/edit-array.d.ts +8 -37
- package/types/webmcp/tools/edit-array.d.ts.map +1 -1
- package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
- package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
- package/types/webmcp/tools/get-data.d.ts +17 -15
- package/types/webmcp/tools/get-data.d.ts.map +1 -1
- package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
- package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
- package/types/webmcp/tools/set-data.d.ts +17 -34
- package/types/webmcp/tools/set-data.d.ts.map +1 -1
- package/types/webmcp/tools/set-field-value.d.ts +20 -57
- package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
- package/types/webmcp/variants-memo.d.ts +42 -0
- package/types/webmcp/variants-memo.d.ts.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@json-layout/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Compilation and state management utilities for JSON Layout.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
"scripts": {
|
|
62
62
|
"test:only": "node --test --test-only test/*.spec.js",
|
|
63
63
|
"test": "mkdir -p tmp && node --test test/*.spec.js",
|
|
64
|
+
"webmcp-eval:report": "node webmcp-eval/report.js",
|
|
65
|
+
"webmcp-eval:run": "node webmcp-eval/run-case.js",
|
|
64
66
|
"build": "rm -rf ./types && tsc -p tsconfig.build.json",
|
|
65
67
|
"watch:build": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
66
68
|
"watch:test": "node --test --watch test/*.spec.js"
|
|
@@ -82,7 +84,7 @@
|
|
|
82
84
|
},
|
|
83
85
|
"homepage": "https://github.com/json-layout/json-layout#readme",
|
|
84
86
|
"peerDependencies": {
|
|
85
|
-
"@json-layout/vocabulary": "^2.13.
|
|
87
|
+
"@json-layout/vocabulary": "^2.13.2"
|
|
86
88
|
},
|
|
87
89
|
"dependencies": {
|
|
88
90
|
"ajv": "^8.17.1",
|
package/src/compile/index.js
CHANGED
|
@@ -37,7 +37,9 @@ export function compile (_schema, partialOptions = {}) {
|
|
|
37
37
|
const schema = /** @type {import('ajv').SchemaObject} */(clone(_schema))
|
|
38
38
|
schema.$id = schema.$id ?? '_jl'
|
|
39
39
|
const getJSONRef = resolveLocaleRefs(schema, options.ajv, options.locale, options.defaultLocale)
|
|
40
|
-
|
|
40
|
+
// always called: when xI18n is off this strips the annotations rather than applying
|
|
41
|
+
// them, so a schema carrying them stays valid instead of failing to normalize
|
|
42
|
+
resolveXI18n(schema, options.locale, options.defaultLocale, options.xI18n)
|
|
41
43
|
|
|
42
44
|
/** @type {string[]} */
|
|
43
45
|
const validatePointers = []
|
|
@@ -4,6 +4,30 @@ import { normalizeLayoutFragment, mergeNullableSubSchema, getSchemaFragmentType
|
|
|
4
4
|
import { makeSkeletonTree } from './skeleton-tree.js'
|
|
5
5
|
import { partialResolveRefs } from './utils/resolve-refs.js'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Validation keywords that change what a value has to look like, as opposed to how it is
|
|
9
|
+
* rendered. A build-time compiled layout does not carry the raw schema — `serialize` emits
|
|
10
|
+
* the skeleton, the layouts and the validators, and nothing else — so a form filler working
|
|
11
|
+
* against a precompiled layout could not learn that a field is an email, or matches a
|
|
12
|
+
* pattern, or that an array holds at most five items. Ajv still enforces every one of them,
|
|
13
|
+
* which made the omission worse than useless: the rule was invisible until it was violated.
|
|
14
|
+
* Numbers are left out because min/max/step already reach the layout.
|
|
15
|
+
*/
|
|
16
|
+
const CONSTRAINT_KEYWORDS = ['format', 'pattern', 'minLength', 'maxLength', 'minItems', 'maxItems', 'uniqueItems']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {any} schema
|
|
20
|
+
* @returns {Record<string, unknown> | undefined}
|
|
21
|
+
*/
|
|
22
|
+
function collectConstraints (schema) {
|
|
23
|
+
/** @type {Record<string, unknown>} */
|
|
24
|
+
const constraints = {}
|
|
25
|
+
for (const keyword of CONSTRAINT_KEYWORDS) {
|
|
26
|
+
if (schema?.[keyword] !== undefined) constraints[keyword] = schema[keyword]
|
|
27
|
+
}
|
|
28
|
+
return Object.keys(constraints).length ? constraints : undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
/**
|
|
8
32
|
* @param {any} rawSchema
|
|
9
33
|
* @param {string} sourceSchemaId
|
|
@@ -17,10 +41,17 @@ import { partialResolveRefs } from './utils/resolve-refs.js'
|
|
|
17
41
|
* @param {import('@json-layout/vocabulary').Expression[]} expressions
|
|
18
42
|
* @param {string | number} key
|
|
19
43
|
* @param {string} pointer
|
|
20
|
-
* @param {boolean} required
|
|
44
|
+
* @param {boolean} required - the schema makes this node's value mandatory: omitting it is
|
|
45
|
+
* a validation error. This is what `node.required` reports and what a UI marks with an
|
|
46
|
+
* asterisk, so it must never be true for a node the schema is happy to see absent.
|
|
21
47
|
* @param {string} [condition]
|
|
22
48
|
* @param {boolean} [dependent]
|
|
23
49
|
* @param {string} [knownType]
|
|
50
|
+
* @param {boolean} [alwaysPresent] - the node's value is materialized whether or not the
|
|
51
|
+
* schema requires it, because its parent renders it as a fixed slot. Drives default and
|
|
52
|
+
* empty-container application only. Defaults to `required`, which is the same thing for
|
|
53
|
+
* every parent that builds its children from what the schema demands; tuple entries are
|
|
54
|
+
* the exception, always present but only required up to `minItems`.
|
|
24
55
|
* @returns {import('./types.js').SkeletonNode}
|
|
25
56
|
*/
|
|
26
57
|
export function makeSkeletonNode (
|
|
@@ -39,7 +70,8 @@ export function makeSkeletonNode (
|
|
|
39
70
|
required,
|
|
40
71
|
condition,
|
|
41
72
|
dependent,
|
|
42
|
-
knownType
|
|
73
|
+
knownType,
|
|
74
|
+
alwaysPresent = required
|
|
43
75
|
) {
|
|
44
76
|
let schemaId = sourceSchemaId
|
|
45
77
|
let schema = rawSchema
|
|
@@ -136,8 +168,8 @@ export function makeSkeletonNode (
|
|
|
136
168
|
if (compObject.getConstData) pushExpression(expressions, compObject.getConstData)
|
|
137
169
|
|
|
138
170
|
let defaultData
|
|
139
|
-
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true ||
|
|
140
|
-
else if (
|
|
171
|
+
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true || alwaysPresent)) defaultData = schema.default
|
|
172
|
+
else if (alwaysPresent) {
|
|
141
173
|
if (nullable) defaultData = null
|
|
142
174
|
else if (type === 'object' && isCompositeLayout(compObject, options.components)) defaultData = {}
|
|
143
175
|
else if (type === 'array') defaultData = []
|
|
@@ -198,6 +230,9 @@ export function makeSkeletonNode (
|
|
|
198
230
|
required: required && !nullable
|
|
199
231
|
}
|
|
200
232
|
|
|
233
|
+
const constraints = collectConstraints(schema)
|
|
234
|
+
if (constraints) node.constraints = constraints
|
|
235
|
+
|
|
201
236
|
if (condition) {
|
|
202
237
|
if (isSwitchStruct(normalizedLayout)) throw new Error('Switch struct not allowed in conditional schema')
|
|
203
238
|
node.condition = { type: 'js-eval', expr: condition, pure: true, dataAlias: 'value' }
|
|
@@ -320,7 +355,7 @@ export function makeSkeletonNode (
|
|
|
320
355
|
const compObjects = isSwitchStruct(normalizationResult.layout) ? normalizationResult.layout.switch : [normalizationResult.layout]
|
|
321
356
|
for (const compObject of compObjects) {
|
|
322
357
|
let defaultData
|
|
323
|
-
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true ||
|
|
358
|
+
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true || alwaysPresent)) defaultData = schema.default
|
|
324
359
|
else defaultData = nullable ? null : {}
|
|
325
360
|
if (compObject.defaultData === undefined) compObject.defaultData = defaultData
|
|
326
361
|
if (compObject.defaultData !== undefined && !compObject.getDefaultData) compObject.getDefaultData = { type: 'js-eval', expr: 'layout.defaultData', pure: true, dataAlias: 'value' }
|
|
@@ -549,6 +584,12 @@ export function makeSkeletonNode (
|
|
|
549
584
|
expressions,
|
|
550
585
|
i,
|
|
551
586
|
childPointer,
|
|
587
|
+
// a tuple entry is only mandatory while minItems still covers its position,
|
|
588
|
+
// but json-layout renders every entry, so all of them are always present
|
|
589
|
+
(schema.minItems ?? 0) > i,
|
|
590
|
+
undefined,
|
|
591
|
+
undefined,
|
|
592
|
+
undefined,
|
|
552
593
|
true
|
|
553
594
|
)
|
|
554
595
|
}
|
package/src/compile/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { clone } from '@json-layout/vocabulary'
|
|
2
2
|
|
|
3
|
+
import { resolvePointerFragment } from '../../utils/json-pointer.js'
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* @param {Record<string, import('ajv').SchemaObject>} schemas
|
|
5
7
|
* @param {import('ajv/dist/2019.js').default} ajv
|
|
@@ -12,14 +14,9 @@ const prepareGetJSONRef = (schemas, ajv) => {
|
|
|
12
14
|
schemas[schemaId] = schemas[schemaId] ?? (ajv.getSchema(schemaId)?.schema)
|
|
13
15
|
if (!schemas[schemaId]) throw new Error(`reference not found ${schemaId}`)
|
|
14
16
|
if (!pointer) return [schemas[schemaId], schemaId, fullRef]
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!(pointerPart in a.value)) throw new Error(`reference not found ${schemaId}#${a.path.join('/')}`)
|
|
19
|
-
a.value = a.value[pointerPart]
|
|
20
|
-
return a
|
|
21
|
-
}, { path: /** @type {string[]} */([]), value: schemas[schemaId] })
|
|
22
|
-
return [fragment, schemaId, fullRef]
|
|
17
|
+
const resolved = resolvePointerFragment(schemas[schemaId], pointer)
|
|
18
|
+
if (!resolved.found) throw new Error(`reference not found ${schemaId}#${resolved.path.join('/')}`)
|
|
19
|
+
return [resolved.value, schemaId, fullRef]
|
|
23
20
|
}
|
|
24
21
|
}
|
|
25
22
|
|
|
@@ -4,21 +4,30 @@
|
|
|
4
4
|
* @param {Record<string, any>} schema
|
|
5
5
|
* @param {string} locale
|
|
6
6
|
* @param {string} [defaultLocale]
|
|
7
|
+
* @param {boolean} [apply] - when false the annotations are stripped without being applied
|
|
7
8
|
*/
|
|
8
|
-
export const resolveXI18n = (schema, locale, defaultLocale = 'en') => {
|
|
9
|
+
export const resolveXI18n = (schema, locale, defaultLocale = 'en', apply = true) => {
|
|
9
10
|
if (!schema || typeof schema !== 'object') return
|
|
10
11
|
for (const [key, value] of Object.entries(schema)) {
|
|
11
12
|
if (key.startsWith('x-i18n-')) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Always removed, even when not applied: these keys belong to this vocabulary, and
|
|
14
|
+
// the option says whether to translate with them, not whether they are legal. Left
|
|
15
|
+
// in place they fail the component schemas' unevaluatedProperties, normalization
|
|
16
|
+
// falls back to the default component, and a one-of-select that stops being a
|
|
17
|
+
// variant selector no longer merges its branch into the parent — the data then
|
|
18
|
+
// never stabilises and updateState throws after 100 iterations.
|
|
19
|
+
if (apply) {
|
|
20
|
+
if (typeof value !== 'object') console.error(`i18n property ${key} should be an object`)
|
|
21
|
+
const realKey = key.replace('x-i18n-', '')
|
|
22
|
+
schema[realKey] = value[locale] ?? value[defaultLocale] ?? schema[realKey]
|
|
23
|
+
}
|
|
15
24
|
delete schema[key]
|
|
16
25
|
} else if (Array.isArray(value)) {
|
|
17
26
|
for (const child of value) {
|
|
18
|
-
resolveXI18n(child, locale, defaultLocale)
|
|
27
|
+
resolveXI18n(child, locale, defaultLocale, apply)
|
|
19
28
|
}
|
|
20
29
|
} else {
|
|
21
|
-
resolveXI18n(value, locale, defaultLocale)
|
|
30
|
+
resolveXI18n(value, locale, defaultLocale, apply)
|
|
22
31
|
}
|
|
23
32
|
}
|
|
24
33
|
}
|
package/src/state/index.js
CHANGED
|
@@ -171,6 +171,19 @@ export class StatefulLayout {
|
|
|
171
171
|
// @ts-ignore
|
|
172
172
|
_lastCreateStateTreeContext
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The raw validation errors of the last state update, each carrying the data pointer
|
|
176
|
+
* (instancePath) it applies to.
|
|
177
|
+
*
|
|
178
|
+
* A node only carries an error when it is hydrated, so an error below an unhydrated
|
|
179
|
+
* subtree — a list item shown in summary mode, say — has no node to attach to and
|
|
180
|
+
* collapses onto the nearest ancestor. These keep the precise location.
|
|
181
|
+
* @returns {import('ajv').ErrorObject[]}
|
|
182
|
+
*/
|
|
183
|
+
get validationErrors () {
|
|
184
|
+
return this._lastCreateStateTreeContext?.allErrors ?? []
|
|
185
|
+
}
|
|
186
|
+
|
|
174
187
|
/**
|
|
175
188
|
* @private
|
|
176
189
|
* @type {string | null}
|
|
@@ -703,7 +716,11 @@ export class StatefulLayout {
|
|
|
703
716
|
item.value = layout.getItems?.itemValue ? this.evalNodeExpression(node, layout.getItems.itemValue, rawItem) : (layout.getItems?.returnObjects ? rawItem : rawItem.value)
|
|
704
717
|
item.key = layout.getItems?.itemKey ? this.evalNodeExpression(node, layout.getItems.itemKey, rawItem) : rawItem.key
|
|
705
718
|
item.title = layout.getItems?.itemTitle ? this.evalNodeExpression(node, layout.getItems.itemTitle, rawItem) : rawItem.title
|
|
706
|
-
|
|
719
|
+
// A legitimate null — a { const: null } branch, normalized to
|
|
720
|
+
// { key: "null", value: null } — is a value, not an absence. `??` would swallow
|
|
721
|
+
// it and keep the key, so the tool hands the agent the string "null" and the
|
|
722
|
+
// schema then rejects the very suggestion it offered.
|
|
723
|
+
if (item.value === undefined) item.value = item.key
|
|
707
724
|
item.key = item.key ?? item.value + ''
|
|
708
725
|
item.title = item.title ?? item.key
|
|
709
726
|
}
|
package/src/state/state-node.js
CHANGED
|
@@ -329,8 +329,17 @@ const getCompObject = (normalizedLayout, childDefinition, options, compiledLayou
|
|
|
329
329
|
if (childDefinition?.if && !evalExpression(compiledLayout.expressions, childDefinition.if, data, options, display, normalizedLayout, compiledLayout.validates, rootData, parentContext)) {
|
|
330
330
|
return noneComp
|
|
331
331
|
}
|
|
332
|
-
if (normalizedLayout.if
|
|
333
|
-
|
|
332
|
+
if (normalizedLayout.if) {
|
|
333
|
+
let visible
|
|
334
|
+
try {
|
|
335
|
+
visible = evalExpression(compiledLayout.expressions, normalizedLayout.if, data, options, display, normalizedLayout, compiledLayout.validates, rootData, parentContext)
|
|
336
|
+
} catch (err) {
|
|
337
|
+
// an "if" that cannot be evaluated (typically a rootData path through an empty
|
|
338
|
+
// array) hides the node, as an unresolvable getItems yields no items
|
|
339
|
+
console.warn(`json-layout: "if" expression threw, treating it as false: ${normalizedLayout.if.expr}`)
|
|
340
|
+
visible = false
|
|
341
|
+
}
|
|
342
|
+
if (!visible) return noneComp
|
|
334
343
|
}
|
|
335
344
|
return normalizedLayout
|
|
336
345
|
}
|
package/src/state/utils/urls.js
CHANGED
|
@@ -3,5 +3,7 @@ const origin = typeof window === 'undefined' ? 'http://test.com' : window.locati
|
|
|
3
3
|
export const pathURL = (/** @type {string} */url, /** @type {string} */baseURL) => {
|
|
4
4
|
if (url.startsWith('http://') || url.startsWith('https://')) return new URL(url)
|
|
5
5
|
if (url.startsWith('/')) return new URL(origin + url)
|
|
6
|
+
// an absolute base (a Node process pointed at a remote API) must not be glued onto the origin
|
|
7
|
+
if (baseURL.startsWith('http://') || baseURL.startsWith('https://')) return new URL(baseURL + url)
|
|
6
8
|
return new URL(origin + baseURL + url)
|
|
7
9
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file JSON pointer traversal, shared by the compilation step and the webmcp tools
|
|
3
|
+
* @description Pointers are produced by concatenation in the compilation step
|
|
4
|
+
* (`${refPointerPrefix}/properties/${propertyKey}` and similar), their segments are
|
|
5
|
+
* therefore consumed raw: they are deliberately not unescaped as RFC 6901 would
|
|
6
|
+
* prescribe, so that resolution stays symmetric with the way pointers are built.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the fragment part of a JSON pointer (what follows the '#') in a schema or any object.
|
|
11
|
+
* @param {unknown} root
|
|
12
|
+
* @param {string} fragment - e.g. '/properties/address/items', leading and empty segments are ignored
|
|
13
|
+
* @returns {{found: true, value: unknown} | {found: false, path: string[]}} - the resolved value, or
|
|
14
|
+
* the segments consumed up to and including the missing one, to report where the resolution failed
|
|
15
|
+
*/
|
|
16
|
+
export function resolvePointerFragment (root, fragment) {
|
|
17
|
+
/** @type {string[]} */
|
|
18
|
+
const path = []
|
|
19
|
+
let current = root
|
|
20
|
+
for (const segment of fragment.split('/')) {
|
|
21
|
+
if (!segment) continue
|
|
22
|
+
path.push(segment)
|
|
23
|
+
if (current === null || typeof current !== 'object' || !(segment in current)) {
|
|
24
|
+
return { found: false, path }
|
|
25
|
+
}
|
|
26
|
+
current = /** @type {any} */(current)[segment]
|
|
27
|
+
}
|
|
28
|
+
return { found: true, value: current }
|
|
29
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# WebMCP form tools — what they are and why they exist
|
|
2
|
+
|
|
3
|
+
A page that renders a json-layout form can expose it to an agent as a set of MCP tools.
|
|
4
|
+
This is the design of those tools, the choices behind them, and how far they have actually
|
|
5
|
+
been validated.
|
|
6
|
+
|
|
7
|
+
## Why not just hand the model the schema and let it emit JSON?
|
|
8
|
+
|
|
9
|
+
This is the first question anyone asks, and for a small form it is the right instinct —
|
|
10
|
+
`contact` is a 368-character schema with five fields, and a model would fill it blind.
|
|
11
|
+
The suite exists because real configuration forms are not that, and four properties break
|
|
12
|
+
the blind approach:
|
|
13
|
+
|
|
14
|
+
**1. Half the schema does not apply until you have chosen something.** `charts` is five
|
|
15
|
+
levels of nested `oneOf`: which fields are legal depends on the chart type, then on the
|
|
16
|
+
data-preparation mode, then on the grouping kind. `calendar` hides a whole section behind
|
|
17
|
+
`x-if: datasets.0.isRest` — the crowd-sourcing settings do not exist as valid keys until a
|
|
18
|
+
REST dataset is picked. A model emitting a document in one shot has to guess which branch's
|
|
19
|
+
fields are legal; the tools let it activate a branch and be told what that branch contains.
|
|
20
|
+
|
|
21
|
+
**2. The accepted values are not in the schema at all.** `calendar`'s label field takes its
|
|
22
|
+
options from `${rootData.datasets[0].href}/schema?calculated=false` — an HTTP request whose
|
|
23
|
+
URL is built from another field's current value. No amount of schema reading yields
|
|
24
|
+
`nomevenement`. `getFieldSuggestions` performs that lookup; nothing else can.
|
|
25
|
+
|
|
26
|
+
**3. The values can be far larger than the answer.** A picked data-fair dataset is a 12,486-
|
|
27
|
+
character object of column definitions. The agent never handles it: it applies suggestion
|
|
28
|
+
index 0 and the form stores the object. Emitting that document by hand would mean
|
|
29
|
+
reproducing 12 KB of column metadata verbatim.
|
|
30
|
+
|
|
31
|
+
**4. The schema is bigger than the whole conversation.** `portal-page` is 285,874 characters,
|
|
32
|
+
814 layout nodes. A complete `portal-page` session costs about 7.4 KB of tool output. Sending
|
|
33
|
+
the schema once would cost roughly forty times the entire interaction.
|
|
34
|
+
|
|
35
|
+
There is a fifth reason that matters in production: the form is *live*. Writes land in the
|
|
36
|
+
state a user is looking at, validation runs on every one, and the errors come back scoped to
|
|
37
|
+
what just changed. The agent is editing a form, not drafting a file.
|
|
38
|
+
|
|
39
|
+
## The tools
|
|
40
|
+
|
|
41
|
+
Six, and one guide. The guide is not a tool — production pages pass it to the agent as its
|
|
42
|
+
prompt via `includeSubAgent`.
|
|
43
|
+
|
|
44
|
+
| tool | what it is for |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `describeState` | every field with path, type, constraints, current value, errors. The starting point and the only way to see the form. |
|
|
47
|
+
| `setFieldValue` | write one field; switch a variant by writing its index. |
|
|
48
|
+
| `setData` | write the whole document at once, for when the goal already contains every value. |
|
|
49
|
+
| `editArray` | add or remove an item; an added item is activated and its fields returned. |
|
|
50
|
+
| `getFieldSuggestions` | fetch the accepted values of a field whose list is remote. |
|
|
51
|
+
| `getData` | the data itself, whole or at a path, for when the values are wanted rather than a description of them. |
|
|
52
|
+
|
|
53
|
+
## Choices, and what justified them
|
|
54
|
+
|
|
55
|
+
Each of these was measured, most of them after getting it wrong first.
|
|
56
|
+
|
|
57
|
+
**Responses are text, never `structuredContent`.** Tools used to return both a markdown text
|
|
58
|
+
and a JSON object. MCP clients substitute the JSON, so the markdown was never read — proved
|
|
59
|
+
by reading the runner's own transcript, where an agent can be seen receiving
|
|
60
|
+
`{"path":"/contactMethod","type":"select","getSuggestions":true}` with no values. Five
|
|
61
|
+
finished pieces of work were inert. Removing the structured half took what the model receives
|
|
62
|
+
for one `describeState` from 91,197 bytes to 2,212.
|
|
63
|
+
|
|
64
|
+
**One representation, one home per fact.** The suggestion protocol was once stated four times
|
|
65
|
+
— in two tool descriptions, a parameter, and the guide. Copies drift: a stale duplicate of
|
|
66
|
+
`getData`'s description in `index.js` silently overrode the real one for weeks and cost two
|
|
67
|
+
cases a call each. Tests now assert that exactly one description explains `suggestionIndex`.
|
|
68
|
+
|
|
69
|
+
**One guide, no complexity classification.** There used to be small/medium/large bands, from
|
|
70
|
+
counting layout nodes, feeding three different sets of advice. The threshold was
|
|
71
|
+
unjustifiable, it measured the schema when the question was about data, and it was duplicated
|
|
72
|
+
in two files. Four consecutive commits fixed contradictions between the branches, and across
|
|
73
|
+
four baselines the small and medium advice was never taken on a real form.
|
|
74
|
+
|
|
75
|
+
**No `getSchema`.** A build-time compiled layout carries no raw schema — `serialize()` emits
|
|
76
|
+
the skeleton, the layouts and the validators — so the tool cannot exist where production runs.
|
|
77
|
+
`calendar` and `charts` had it available in every run and never called it. What it uniquely
|
|
78
|
+
offered, the validation keywords, now reaches the state tree instead: `format`, `pattern`,
|
|
79
|
+
`minLength`, `maxLength`, `minItems`, `maxItems` and `uniqueItems` are carried onto the
|
|
80
|
+
skeleton node, because ajv enforces them and nothing else was telling the agent.
|
|
81
|
+
|
|
82
|
+
**Large values are named, never truncated.** `<object, 12486 chars — call getData with this
|
|
83
|
+
path to read it>`. A truncated value read as real would be forwarded to an API as a wrong
|
|
84
|
+
value with nothing looking wrong; a marker fails loudly. `getData` itself is never abbreviated
|
|
85
|
+
for the same reason — what it returns is the data.
|
|
86
|
+
|
|
87
|
+
**A closed list is stated, a remote one is flagged.** `describeState` prints
|
|
88
|
+
`values=["top","bottom","left","right"]` when the options are already resolved, and marks
|
|
89
|
+
`suggestions` when they are only reachable by request. Before this, agents spent a round trip
|
|
90
|
+
per enum.
|
|
91
|
+
|
|
92
|
+
**Repeated things are said once.** A recursive schema reaches the same union at every level;
|
|
93
|
+
`portal-page`'s 39 branches were printed three times in a ten-call run. They are now printed
|
|
94
|
+
once, then referred back to by path.
|
|
95
|
+
|
|
96
|
+
**Memorised suggestions are dropped only when they go stale.** Applying an option by index
|
|
97
|
+
needs the list remembered. Every write used to clear all of it, which broke a listing an
|
|
98
|
+
unrelated write could not have affected. Invalidation now compares each path's
|
|
99
|
+
`itemsCacheKey` — the same value the state layer uses to decide whether to re-fetch.
|
|
100
|
+
|
|
101
|
+
## How far this is validated
|
|
102
|
+
|
|
103
|
+
Unit tests: 463, covering tool shapes, projection rules and the compile-level changes in both
|
|
104
|
+
runtime and build-time compilation.
|
|
105
|
+
|
|
106
|
+
Behavioural: `core/webmcp-eval/` runs each case as a headless `claude -p` subprocess against
|
|
107
|
+
a real MCP server, and a judge reads the transcript. Six cases — a hand-written control, three
|
|
108
|
+
vendored from data-fair (`app-calendar`, `app-charts`, `portals` page editor), and two that
|
|
109
|
+
start from a populated document and ask for a change.
|
|
110
|
+
|
|
111
|
+
At the latest baseline, every case was judged satisfactory on **three model tiers**, with the
|
|
112
|
+
produced document checked field by field rather than trusting `valid: true`:
|
|
113
|
+
|
|
114
|
+
| | opus | sonnet | haiku |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| cases correct | 6/6 | 6/6 | 6/6 |
|
|
117
|
+
| errored calls | 1 | 0 | 0 |
|
|
118
|
+
| suite cost | $0.838 | $0.502 | $0.254 |
|
|
119
|
+
|
|
120
|
+
Haiku 4.5 completed `portal-page` — the 39-branch recursive union — in eight calls, the floor,
|
|
121
|
+
with no wasted read. The points where a smaller model was expected to slip are the ones the
|
|
122
|
+
protocol leaves to inference: which of `children`/`children2` is the left column, that a
|
|
123
|
+
rendered branch is the active one, that a field's options cannot be fetched before the field
|
|
124
|
+
they derive from is set. None of them bit.
|
|
125
|
+
|
|
126
|
+
## What is not established
|
|
127
|
+
|
|
128
|
+
- **One MCP client.** Everything ran through Claude Code. The `structuredContent` episode is
|
|
129
|
+
the standing reminder that client behaviour can silently invalidate a whole line of work.
|
|
130
|
+
- **The judge is not independent** — it is the same model family reading its own transcripts.
|
|
131
|
+
- **n=1 per case per model**, against run-to-run variance that has reached 3× on cost for
|
|
132
|
+
identical code. Single-run comparisons are noise; only repeated, large effects are signal.
|
|
133
|
+
- **`setData` with `merge: false`** has unit tests and no behavioural evidence — no agent has
|
|
134
|
+
ever called it.
|
|
135
|
+
- Open findings are recorded per baseline in `core/webmcp-eval/baselines/`. All current ones
|
|
136
|
+
are clarity, not correctness: none has produced a wrong value or an invalid form.
|
|
137
|
+
|
|
138
|
+
## A note on measuring
|
|
139
|
+
|
|
140
|
+
Output bytes are what a tool answers with; they are not what a run costs. A `calendar` run
|
|
141
|
+
consumes about 98,500 input-side tokens, 89,000 of them cache reads of the conversation
|
|
142
|
+
replayed each turn, against roughly 1,300 output. One 13 KB response is about 3% of that.
|
|
143
|
+
Three commits went into suppressing such a response before anyone measured it. The runner now
|
|
144
|
+
records token usage and the report prints it beside the byte count.
|