@json-layout/core 2.8.2 → 2.9.1

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 (64) hide show
  1. package/package.json +4 -2
  2. package/src/compile/index.js +3 -1
  3. package/src/compile/skeleton-node.js +46 -5
  4. package/src/compile/types.ts +1 -0
  5. package/src/compile/utils/resolve-refs.js +5 -8
  6. package/src/compile/utils/x-i18n.js +15 -6
  7. package/src/state/index.js +18 -1
  8. package/src/state/state-node.js +11 -2
  9. package/src/state/utils/urls.js +2 -0
  10. package/src/utils/json-pointer.js +29 -0
  11. package/src/webmcp/README.md +144 -0
  12. package/src/webmcp/index.js +88 -82
  13. package/src/webmcp/project.js +542 -111
  14. package/src/webmcp/resolve.js +37 -1
  15. package/src/webmcp/schema.js +169 -0
  16. package/src/webmcp/suggestions-store.js +121 -0
  17. package/src/webmcp/tools/describe-state.js +20 -64
  18. package/src/webmcp/tools/edit-array.js +51 -28
  19. package/src/webmcp/tools/fill-form-skill.js +17 -41
  20. package/src/webmcp/tools/get-data.js +66 -13
  21. package/src/webmcp/tools/get-field-suggestions.js +12 -23
  22. package/src/webmcp/tools/set-data.js +79 -28
  23. package/src/webmcp/tools/set-field-value.js +88 -39
  24. package/src/webmcp/variants-memo.js +53 -0
  25. package/types/compile/index.d.ts.map +1 -1
  26. package/types/compile/skeleton-node.d.ts +9 -2
  27. package/types/compile/skeleton-node.d.ts.map +1 -1
  28. package/types/compile/types.d.ts +1 -0
  29. package/types/compile/types.d.ts.map +1 -1
  30. package/types/compile/utils/resolve-refs.d.ts.map +1 -1
  31. package/types/compile/utils/x-i18n.d.ts +1 -1
  32. package/types/compile/utils/x-i18n.d.ts.map +1 -1
  33. package/types/state/index.d.ts +10 -0
  34. package/types/state/index.d.ts.map +1 -1
  35. package/types/state/state-node.d.ts.map +1 -1
  36. package/types/state/utils/urls.d.ts.map +1 -1
  37. package/types/utils/json-pointer.d.ts +22 -0
  38. package/types/utils/json-pointer.d.ts.map +1 -0
  39. package/types/webmcp/index.d.ts +23 -15
  40. package/types/webmcp/index.d.ts.map +1 -1
  41. package/types/webmcp/project.d.ts +178 -57
  42. package/types/webmcp/project.d.ts.map +1 -1
  43. package/types/webmcp/resolve.d.ts +7 -3
  44. package/types/webmcp/resolve.d.ts.map +1 -1
  45. package/types/webmcp/schema.d.ts +44 -0
  46. package/types/webmcp/schema.d.ts.map +1 -0
  47. package/types/webmcp/suggestions-store.d.ts +82 -0
  48. package/types/webmcp/suggestions-store.d.ts.map +1 -0
  49. package/types/webmcp/tools/describe-state.d.ts +5 -57
  50. package/types/webmcp/tools/describe-state.d.ts.map +1 -1
  51. package/types/webmcp/tools/edit-array.d.ts +8 -37
  52. package/types/webmcp/tools/edit-array.d.ts.map +1 -1
  53. package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
  54. package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
  55. package/types/webmcp/tools/get-data.d.ts +17 -15
  56. package/types/webmcp/tools/get-data.d.ts.map +1 -1
  57. package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
  58. package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
  59. package/types/webmcp/tools/set-data.d.ts +17 -34
  60. package/types/webmcp/tools/set-data.d.ts.map +1 -1
  61. package/types/webmcp/tools/set-field-value.d.ts +20 -57
  62. package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
  63. package/types/webmcp/variants-memo.d.ts +42 -0
  64. package/types/webmcp/variants-memo.d.ts.map +1 -0
@@ -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
  })