@json-layout/core 2.8.1 → 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.
Files changed (69) hide show
  1. package/package.json +4 -2
  2. package/src/compile/index.js +3 -1
  3. package/src/compile/serialize.js +18 -15
  4. package/src/compile/skeleton-node.js +71 -5
  5. package/src/compile/types.ts +1 -0
  6. package/src/compile/utils/resolve-refs.js +5 -8
  7. package/src/compile/utils/x-i18n.js +15 -6
  8. package/src/state/index.js +46 -1
  9. package/src/state/state-node.js +36 -5
  10. package/src/state/types.ts +6 -0
  11. package/src/state/utils/urls.js +2 -0
  12. package/src/utils/json-pointer.js +29 -0
  13. package/src/webmcp/README.md +144 -0
  14. package/src/webmcp/index.js +88 -82
  15. package/src/webmcp/project.js +479 -111
  16. package/src/webmcp/resolve.js +37 -1
  17. package/src/webmcp/schema.js +169 -0
  18. package/src/webmcp/suggestions-store.js +121 -0
  19. package/src/webmcp/tools/describe-state.js +20 -64
  20. package/src/webmcp/tools/edit-array.js +51 -28
  21. package/src/webmcp/tools/fill-form-skill.js +17 -41
  22. package/src/webmcp/tools/get-data.js +66 -13
  23. package/src/webmcp/tools/get-field-suggestions.js +12 -23
  24. package/src/webmcp/tools/set-data.js +79 -28
  25. package/src/webmcp/tools/set-field-value.js +49 -39
  26. package/src/webmcp/variants-memo.js +53 -0
  27. package/types/compile/index.d.ts.map +1 -1
  28. package/types/compile/serialize.d.ts.map +1 -1
  29. package/types/compile/skeleton-node.d.ts +9 -2
  30. package/types/compile/skeleton-node.d.ts.map +1 -1
  31. package/types/compile/types.d.ts +1 -0
  32. package/types/compile/types.d.ts.map +1 -1
  33. package/types/compile/utils/resolve-refs.d.ts.map +1 -1
  34. package/types/compile/utils/x-i18n.d.ts +1 -1
  35. package/types/compile/utils/x-i18n.d.ts.map +1 -1
  36. package/types/state/index.d.ts +23 -0
  37. package/types/state/index.d.ts.map +1 -1
  38. package/types/state/state-node.d.ts.map +1 -1
  39. package/types/state/types.d.ts +2 -0
  40. package/types/state/types.d.ts.map +1 -1
  41. package/types/state/utils/urls.d.ts.map +1 -1
  42. package/types/utils/json-pointer.d.ts +22 -0
  43. package/types/utils/json-pointer.d.ts.map +1 -0
  44. package/types/webmcp/index.d.ts +23 -15
  45. package/types/webmcp/index.d.ts.map +1 -1
  46. package/types/webmcp/project.d.ts +159 -57
  47. package/types/webmcp/project.d.ts.map +1 -1
  48. package/types/webmcp/resolve.d.ts +7 -3
  49. package/types/webmcp/resolve.d.ts.map +1 -1
  50. package/types/webmcp/schema.d.ts +44 -0
  51. package/types/webmcp/schema.d.ts.map +1 -0
  52. package/types/webmcp/suggestions-store.d.ts +82 -0
  53. package/types/webmcp/suggestions-store.d.ts.map +1 -0
  54. package/types/webmcp/tools/describe-state.d.ts +5 -57
  55. package/types/webmcp/tools/describe-state.d.ts.map +1 -1
  56. package/types/webmcp/tools/edit-array.d.ts +8 -37
  57. package/types/webmcp/tools/edit-array.d.ts.map +1 -1
  58. package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
  59. package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
  60. package/types/webmcp/tools/get-data.d.ts +17 -15
  61. package/types/webmcp/tools/get-data.d.ts.map +1 -1
  62. package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
  63. package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
  64. package/types/webmcp/tools/set-data.d.ts +17 -34
  65. package/types/webmcp/tools/set-data.d.ts.map +1 -1
  66. package/types/webmcp/tools/set-field-value.d.ts +20 -57
  67. package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
  68. package/types/webmcp/variants-memo.d.ts +42 -0
  69. package/types/webmcp/variants-memo.d.ts.map +1 -0
@@ -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.
@@ -12,7 +12,10 @@ import * as getData from './tools/get-data.js'
12
12
  import * as getFieldSuggestions from './tools/get-field-suggestions.js'
13
13
  import * as editArray from './tools/edit-array.js'
14
14
  import * as fillFormSkill from './tools/fill-form-skill.js'
15
- import { formatMutationResult, formatSuggestions } from './project.js'
15
+ import { formatMutationResult, formatSuggestions, projectSuggestions, abbreviateValue, formatVisibilityDiff, suggestionsBlocked, suggestionsSource } from './project.js'
16
+ import { resolveNode } from './resolve.js'
17
+ import { SuggestionsStore } from './suggestions-store.js'
18
+ import { VariantsMemo } from './variants-memo.js'
16
19
 
17
20
  /** @typedef {import('@mcp-b/webmcp-types').ToolDescriptor} ToolDescriptor */
18
21
 
@@ -42,22 +45,10 @@ function parseIfJsonString (value) {
42
45
  * @typedef {object} WebMCPOptions
43
46
  * @property {string} [prefixName] - Prefix for all tool names
44
47
  * @property {string} [dataTitle] - Title used in descriptions (default: 'form')
45
- * @property {object} [schema] - The original JSON schema
46
48
  * @property {boolean} [includeFillFormSkill] - Include the fillFormSkill tool (default: false)
47
49
  * @property {boolean} [includeSubAgent] - Include a subagent_ tool wrapping all form tools (default: false)
48
50
  */
49
51
 
50
- /**
51
- * @param {import('../state/index.js').StatefulLayout} statefulLayout
52
- * @returns {"small"|"medium"|"large"}
53
- */
54
- function getComplexity (statefulLayout) {
55
- const nbNormalizedLayouts = Object.keys(statefulLayout.compiledLayout.normalizedLayouts).length
56
- if (nbNormalizedLayouts > 50) return 'large'
57
- if (nbNormalizedLayouts > 15) return 'medium'
58
- return 'small'
59
- }
60
-
61
52
  /**
62
53
  * WebMCP class that provides MCP tool descriptors for a StatefulLayout instance
63
54
  */
@@ -82,32 +73,35 @@ export class WebMCP {
82
73
 
83
74
  /**
84
75
  * @readonly
85
- * @type {"small"|"medium"|"large"}
76
+ * @type {boolean}
86
77
  */
87
- _complexity
78
+ _includeFillFormSkill = false
88
79
 
89
80
  /**
90
81
  * @readonly
91
- * @type {object | null}
82
+ * @type {boolean}
92
83
  */
93
- _schema = null
84
+ _includeSubAgent = false
94
85
 
95
86
  /**
96
- * @readonly
97
- * @type {boolean}
87
+ * @type {string[]}
98
88
  */
99
- _includeFillFormSkill = false
89
+ _registeredTools = []
100
90
 
101
91
  /**
92
+ * memory of the last suggestions per node path, used by setFieldValue's suggestionIndex
102
93
  * @readonly
103
- * @type {boolean}
94
+ * @type {SuggestionsStore}
104
95
  */
105
- _includeSubAgent = false
96
+ _suggestionsStore = new SuggestionsStore()
106
97
 
107
98
  /**
108
- * @type {string[]}
99
+ * Variant lists already sent to the agent. Never cleared on a write: a schema's branches
100
+ * are a constant, so unlike memorized suggestions nothing about the data can invalidate
101
+ * them.
102
+ * @type {VariantsMemo}
109
103
  */
110
- _registeredTools = []
104
+ _variantsMemo = new VariantsMemo()
111
105
 
112
106
  /**
113
107
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
@@ -117,10 +111,8 @@ export class WebMCP {
117
111
  this._statefulLayout = statefulLayout
118
112
  this._prefixName = options.prefixName || ''
119
113
  this._dataTitle = options.dataTitle || 'form'
120
- this._schema = options.schema || null
121
114
  this._includeFillFormSkill = options.includeFillFormSkill || false
122
115
  this._includeSubAgent = options.includeSubAgent || false
123
- this._complexity = getComplexity(statefulLayout)
124
116
  }
125
117
 
126
118
  /**
@@ -136,17 +128,15 @@ export class WebMCP {
136
128
  */
137
129
  getTools () {
138
130
  const dataTitle = this._dataTitle
139
- const complexity = this._complexity
140
131
 
141
132
  /** @type {ToolDescriptor[]} */
142
133
  const tools = []
143
134
 
144
135
  if (this._includeFillFormSkill) {
145
- const skill = fillFormSkill.generateSkill(dataTitle, this._prefixName, !!this._schema, this._statefulLayout)
136
+ const skill = fillFormSkill.generateSkill(dataTitle, this._prefixName)
146
137
  tools.push({
147
138
  name: this._toolName('fillFormSkill'),
148
139
  description: fillFormSkill.getDescription(dataTitle),
149
- outputSchema: { type: 'string' },
150
140
  execute: async (args) => {
151
141
  try {
152
142
  return {
@@ -166,15 +156,18 @@ export class WebMCP {
166
156
  tools.push(
167
157
  {
168
158
  name: this._toolName('getData'),
169
- description: `Get current "${dataTitle}" data and validity status. Call this first to see what data already exists.`,
159
+ description: getData.getDescription(dataTitle),
170
160
  inputSchema: getData.inputSchema,
171
- outputSchema: getData.outputSchema,
172
161
  execute: async (args) => {
173
162
  try {
174
163
  const result = getData.execute(this._statefulLayout, args || {})
164
+ // Returned whole, always. This tool's answer IS the data: an agent may hand it
165
+ // to an API, and a document with named placeholders where its values should be
166
+ // would be forwarded as those strings with nothing looking wrong. Volume on a
167
+ // large form is a question of when to call this at all, which the guide
168
+ // answers — not a licence for the tool to answer with something else.
175
169
  return {
176
- content: [{ type: 'text', text: JSON.stringify(result) }],
177
- structuredContent: result
170
+ content: [{ type: 'text', text: JSON.stringify(result) }]
178
171
  }
179
172
  } catch (err) {
180
173
  const message = err instanceof Error ? err.message : String(err)
@@ -187,9 +180,8 @@ export class WebMCP {
187
180
  },
188
181
  {
189
182
  name: this._toolName('setData'),
190
- description: setData.getDescription(dataTitle, complexity),
183
+ description: setData.getDescription(dataTitle),
191
184
  inputSchema: setData.inputSchema,
192
- outputSchema: setData.outputSchema,
193
185
  execute: async (args) => {
194
186
  try {
195
187
  if (!args?.data) {
@@ -198,11 +190,22 @@ export class WebMCP {
198
190
  args.data = parseIfJsonString(args.data)
199
191
  const result = setData.execute(
200
192
  this._statefulLayout,
201
- /** @type {{ data: unknown }} */(args)
193
+ /** @type {{ data: unknown, merge?: boolean }} */(args)
202
194
  )
195
+ // the whole data was replaced, what a memorized path designates may have changed
196
+ this._suggestionsStore.clear()
197
+ const warnings = []
198
+ if (result.removed.length) {
199
+ warnings.push(`removed ${result.removed.length} key(s) not present in the data you passed: ${result.removed.join(', ')} — pass merge=true to keep them`)
200
+ }
201
+ if (result.unknownKeys.length) {
202
+ warnings.push(`${result.unknownKeys.length} key(s) match no field of this form and were ignored by it: ${result.unknownKeys.join(', ')} — check for a typo with describeState`)
203
+ }
204
+ const visibilityInfo = result.visibility ? formatVisibilityDiff(result.visibility).replace(/^\n/, '') : ''
205
+ const stored = result.written.length ? `stored ${result.written.length} key(s): ${result.written.join(', ')}` : ''
206
+ const text = [formatMutationResult(result.valid, result.errors), ...(stored ? [stored] : []), ...(visibilityInfo ? [visibilityInfo] : []), ...warnings].join('\n')
203
207
  return {
204
- content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors) }],
205
- structuredContent: result
208
+ content: [{ type: 'text', text }]
206
209
  }
207
210
  } catch (err) {
208
211
  const message = err instanceof Error ? err.message : String(err)
@@ -215,16 +218,13 @@ export class WebMCP {
215
218
  },
216
219
  {
217
220
  name: this._toolName('describeState'),
218
- description: describeState.getDescription(dataTitle, complexity),
221
+ description: describeState.getDescription(dataTitle),
219
222
  inputSchema: describeState.inputSchema,
220
- outputSchema: describeState.outputSchema,
221
223
  execute: async (args) => {
222
224
  try {
223
- const result = describeState.execute(this._statefulLayout, args || {})
224
- const text = describeState.toMarkdown(this._statefulLayout, args || {})
225
+ const text = describeState.toMarkdown(this._statefulLayout, args || {}, this._variantsMemo)
225
226
  return {
226
- content: [{ type: 'text', text }],
227
- structuredContent: result
227
+ content: [{ type: 'text', text }]
228
228
  }
229
229
  } catch (err) {
230
230
  const message = err instanceof Error ? err.message : String(err)
@@ -239,21 +239,34 @@ export class WebMCP {
239
239
  name: this._toolName('setFieldValue'),
240
240
  description: setFieldValue.getDescription(dataTitle),
241
241
  inputSchema: setFieldValue.inputSchema,
242
- outputSchema: setFieldValue.outputSchema,
243
242
  execute: async (args) => {
244
243
  try {
245
244
  if (!args?.path) {
246
245
  throw new Error('path is required')
247
246
  }
248
- args.value = parseIfJsonString(args.value)
247
+ if (args.value !== undefined) args.value = parseIfJsonString(args.value)
249
248
  const result = setFieldValue.execute(
250
249
  this._statefulLayout,
251
- /** @type {{ path: string, value: unknown }} */(args)
250
+ /** @type {{ path: string, value?: unknown, suggestionIndex?: number }} */(args),
251
+ this._suggestionsStore,
252
+ this._variantsMemo
252
253
  )
253
- const fieldInfo = `${result.field.path} (${result.field.type}) = ${JSON.stringify(result.field.data)}`
254
+ // A getItems expression can depend on another field, so this write may have
255
+ // changed the options of a field memorized under an unchanged path — but only
256
+ // of a field whose list actually depends on it. Comparing each memorized path's
257
+ // itemsCacheKey against the node's current one is how the state layer itself
258
+ // decides whether to re-fetch.
259
+ this._suggestionsStore.retainFresh((path, cacheKey) => {
260
+ const node = resolveNode(this._statefulLayout.stateTree.root, path)
261
+ return !!node && node.itemsCacheKey === cacheKey
262
+ })
263
+ let fieldInfo = `${result.field.path} (${result.field.type}) = ${abbreviateValue(result.field.data)}`
264
+ if (result.visibility) fieldInfo += formatVisibilityDiff(result.visibility)
265
+ if (result.activatedMarkdown) {
266
+ fieldInfo += `\nFields of the activated variant:\n${result.activatedMarkdown}`
267
+ }
254
268
  return {
255
- content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors, fieldInfo) }],
256
- structuredContent: result
269
+ content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors, fieldInfo, result.otherErrors) }]
257
270
  }
258
271
  } catch (err) {
259
272
  const message = err instanceof Error ? err.message : String(err)
@@ -266,9 +279,8 @@ export class WebMCP {
266
279
  },
267
280
  {
268
281
  name: this._toolName('getFieldSuggestions'),
269
- description: `Get allowed values for a dropdown or autocomplete field in "${dataTitle}". Required when describeState shows "suggestions" for a field. Pass the returned value directly to setFieldValue or include it in setData.`,
282
+ description: getFieldSuggestions.getDescription(dataTitle),
270
283
  inputSchema: getFieldSuggestions.inputSchema,
271
- outputSchema: getFieldSuggestions.outputSchema,
272
284
  execute: async (args) => {
273
285
  try {
274
286
  if (!args?.path) {
@@ -276,11 +288,17 @@ export class WebMCP {
276
288
  }
277
289
  const result = await getFieldSuggestions.execute(
278
290
  this._statefulLayout,
279
- /** @type {{ path: string, query?: string }} */(args)
291
+ /** @type {{ path: string, query?: string }} */(args),
292
+ this._suggestionsStore
280
293
  )
294
+ const suggestions = projectSuggestions(result.items, result.baseIndex)
295
+ // An empty answer has two causes the agent must tell apart: a query that
296
+ // matched nothing, and a list whose request could not be built because another
297
+ // field is still empty. Only the second is a reason to go somewhere else.
298
+ const node = resolveNode(this._statefulLayout.stateTree.root, /** @type {any} */(args).path)
299
+ const blockedOn = node && suggestionsBlocked(node) ? suggestionsSource(node) : undefined
281
300
  return {
282
- content: [{ type: 'text', text: formatSuggestions(result.items) }],
283
- structuredContent: result
301
+ content: [{ type: 'text', text: formatSuggestions(suggestions, blockedOn) }]
284
302
  }
285
303
  } catch (err) {
286
304
  const message = err instanceof Error ? err.message : String(err)
@@ -295,7 +313,6 @@ export class WebMCP {
295
313
  name: this._toolName('editArray'),
296
314
  description: editArray.getDescription(dataTitle),
297
315
  inputSchema: editArray.inputSchema,
298
- outputSchema: editArray.outputSchema,
299
316
  execute: async (args) => {
300
317
  try {
301
318
  if (!args?.path || !args?.action) {
@@ -306,14 +323,20 @@ export class WebMCP {
306
323
  }
307
324
  const result = editArray.execute(
308
325
  this._statefulLayout,
309
- /** @type {{ path: string, action: 'add'|'remove', index?: number, value?: unknown }} */(args)
326
+ /** @type {{ path: string, action: 'add'|'remove', index?: number, value?: unknown }} */(args),
327
+ this._variantsMemo
310
328
  )
311
- const actionInfo = args.action === 'add'
312
- ? `added item, ${result.itemCount} total`
313
- : `removed item, ${result.itemCount} remaining`
329
+ // adding or removing an item shifts the paths of the items after it, so the
330
+ // suggestions memorized for those paths now designate another item
331
+ this._suggestionsStore.clear()
332
+ let actionInfo = args.action === 'add'
333
+ ? `added item at index ${result.index}, ${result.itemCount} total`
334
+ : `removed item at index ${result.index}, ${result.itemCount} remaining`
335
+ if (result.itemMarkdown) {
336
+ actionInfo += `\nFields of the new item (activated for edition):\n${result.itemMarkdown}`
337
+ }
314
338
  return {
315
- content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors, actionInfo) }],
316
- structuredContent: result
339
+ content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors, actionInfo, result.otherErrors) }]
317
340
  }
318
341
  } catch (err) {
319
342
  const message = err instanceof Error ? err.message : String(err)
@@ -326,26 +349,9 @@ export class WebMCP {
326
349
  }
327
350
  )
328
351
 
329
- if (this._schema) {
330
- tools.push({
331
- name: this._toolName('getSchema'),
332
- description: `Get the JSON schema that governs the "${dataTitle}" form.`,
333
- outputSchema: {
334
- type: 'object',
335
- description: 'The JSON schema definition'
336
- },
337
- execute: async (args) => {
338
- return {
339
- content: [{ type: 'text', text: JSON.stringify(this._schema) }],
340
- structuredContent: this._schema
341
- }
342
- }
343
- })
344
- }
345
-
346
352
  if (this._includeSubAgent) {
347
353
  const toolNames = tools.map(t => t.name)
348
- const prompt = fillFormSkill.generateSkill(dataTitle, this._prefixName, !!this._schema, this._statefulLayout)
354
+ const prompt = fillFormSkill.generateSkill(dataTitle, this._prefixName)
349
355
  tools.push({
350
356
  name: `subagent_${this._toolName('form')}`,
351
357
  description: `Delegate a form-filling task for "${dataTitle}" to a specialized sub-agent`,
@@ -357,9 +363,9 @@ export class WebMCP {
357
363
  required: ['task']
358
364
  },
359
365
  execute: async () => {
366
+ const config = { prompt, tools: toolNames }
360
367
  return {
361
- content: [{ type: 'text', text: JSON.stringify({ prompt, tools: toolNames }) }],
362
- structuredContent: { prompt, tools: toolNames }
368
+ content: [{ type: 'text', text: JSON.stringify(config) }]
363
369
  }
364
370
  }
365
371
  })