@json-layout/core 2.5.1 → 2.6.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 (33) hide show
  1. package/package.json +2 -1
  2. package/src/compile/serialize.js +4 -0
  3. package/src/state/index.js +25 -1
  4. package/src/state/state-node.js +122 -73
  5. package/src/state/types.ts +5 -1
  6. package/src/state/utils/modified.js +32 -0
  7. package/src/webmcp/index.js +141 -22
  8. package/src/webmcp/project.js +210 -35
  9. package/src/webmcp/tools/describe-state.js +28 -3
  10. package/src/webmcp/tools/edit-array.js +97 -0
  11. package/src/webmcp/tools/fill-form-skill.js +2 -2
  12. package/src/webmcp/tools/set-data.js +8 -6
  13. package/src/webmcp/tools/set-field-value.js +19 -7
  14. package/types/compile/serialize.d.ts.map +1 -1
  15. package/types/state/index.d.ts +14 -1
  16. package/types/state/index.d.ts.map +1 -1
  17. package/types/state/state-node.d.ts.map +1 -1
  18. package/types/state/types.d.ts +5 -1
  19. package/types/state/types.d.ts.map +1 -1
  20. package/types/state/utils/modified.d.ts +17 -0
  21. package/types/state/utils/modified.d.ts.map +1 -0
  22. package/types/webmcp/index.d.ts +21 -9
  23. package/types/webmcp/index.d.ts.map +1 -1
  24. package/types/webmcp/project.d.ts +80 -28
  25. package/types/webmcp/project.d.ts.map +1 -1
  26. package/types/webmcp/tools/describe-state.d.ts +10 -1
  27. package/types/webmcp/tools/describe-state.d.ts.map +1 -1
  28. package/types/webmcp/tools/edit-array.d.ts +88 -0
  29. package/types/webmcp/tools/edit-array.d.ts.map +1 -0
  30. package/types/webmcp/tools/set-data.d.ts +11 -16
  31. package/types/webmcp/tools/set-data.d.ts.map +1 -1
  32. package/types/webmcp/tools/set-field-value.d.ts +35 -17
  33. package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
@@ -10,18 +10,53 @@ import * as setFieldValue from './tools/set-field-value.js'
10
10
  import * as setData from './tools/set-data.js'
11
11
  import * as getData from './tools/get-data.js'
12
12
  import * as getFieldSuggestions from './tools/get-field-suggestions.js'
13
+ import * as editArray from './tools/edit-array.js'
13
14
  import * as fillFormSkill from './tools/fill-form-skill.js'
15
+ import { formatMutationResult, formatSuggestions } from './project.js'
14
16
 
15
17
  /** @typedef {import('@mcp-b/webmcp-types').ToolDescriptor} ToolDescriptor */
16
18
 
17
19
  const log = debug('jl:webmcp')
18
20
 
21
+ /**
22
+ * If value is a JSON string representing an object or array, parse it.
23
+ * Otherwise return value unchanged.
24
+ * @param {unknown} value
25
+ * @returns {unknown}
26
+ */
27
+ function parseIfJsonString (value) {
28
+ if (typeof value !== 'string') return value
29
+ const trimmed = value.trim()
30
+ if ((trimmed[0] === '{' && trimmed[trimmed.length - 1] === '}') ||
31
+ (trimmed[0] === '[' && trimmed[trimmed.length - 1] === ']')) {
32
+ try {
33
+ return JSON.parse(trimmed)
34
+ } catch {
35
+ return value
36
+ }
37
+ }
38
+ return value
39
+ }
40
+
19
41
  /**
20
42
  * @typedef {object} WebMCPOptions
21
43
  * @property {string} [prefixName] - Prefix for all tool names
22
44
  * @property {string} [dataTitle] - Title used in descriptions (default: 'form')
23
45
  * @property {object} [schema] - The original JSON schema
46
+ * @property {boolean} [includeFillFormSkill] - Include the fillFormSkill tool (default: false)
47
+ * @property {boolean} [includeSubAgent] - Include a subagent_ tool wrapping all form tools (default: false)
48
+ */
49
+
50
+ /**
51
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
52
+ * @returns {"small"|"medium"|"large"}
24
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
+ }
25
60
 
26
61
  /**
27
62
  * WebMCP class that provides MCP tool descriptors for a StatefulLayout instance
@@ -47,9 +82,9 @@ export class WebMCP {
47
82
 
48
83
  /**
49
84
  * @readonly
50
- * @type {string}
85
+ * @type {"small"|"medium"|"large"}
51
86
  */
52
- _skill
87
+ _complexity
53
88
 
54
89
  /**
55
90
  * @readonly
@@ -57,6 +92,18 @@ export class WebMCP {
57
92
  */
58
93
  _schema = null
59
94
 
95
+ /**
96
+ * @readonly
97
+ * @type {boolean}
98
+ */
99
+ _includeFillFormSkill = false
100
+
101
+ /**
102
+ * @readonly
103
+ * @type {boolean}
104
+ */
105
+ _includeSubAgent = false
106
+
60
107
  /**
61
108
  * @type {string[]}
62
109
  */
@@ -71,7 +118,9 @@ export class WebMCP {
71
118
  this._prefixName = options.prefixName || ''
72
119
  this._dataTitle = options.dataTitle || 'form'
73
120
  this._schema = options.schema || null
74
- this._skill = fillFormSkill.generateSkill(this._dataTitle, this._prefixName, !!this._schema, this._statefulLayout)
121
+ this._includeFillFormSkill = options.includeFillFormSkill || false
122
+ this._includeSubAgent = options.includeSubAgent || false
123
+ this._complexity = getComplexity(statefulLayout)
75
124
  }
76
125
 
77
126
  /**
@@ -87,17 +136,21 @@ export class WebMCP {
87
136
  */
88
137
  getTools () {
89
138
  const dataTitle = this._dataTitle
139
+ const complexity = this._complexity
90
140
 
91
141
  /** @type {ToolDescriptor[]} */
92
- const tools = [
93
- {
142
+ const tools = []
143
+
144
+ if (this._includeFillFormSkill) {
145
+ const skill = fillFormSkill.generateSkill(dataTitle, this._prefixName, !!this._schema, this._statefulLayout)
146
+ tools.push({
94
147
  name: this._toolName('fillFormSkill'),
95
148
  description: fillFormSkill.getDescription(dataTitle),
96
149
  outputSchema: { type: 'string' },
97
150
  execute: async (args) => {
98
151
  try {
99
152
  return {
100
- content: [{ type: 'text', text: this._skill }]
153
+ content: [{ type: 'text', text: skill }]
101
154
  }
102
155
  } catch (err) {
103
156
  const message = err instanceof Error ? err.message : String(err)
@@ -107,17 +160,21 @@ export class WebMCP {
107
160
  }
108
161
  }
109
162
  }
110
- },
163
+ })
164
+ }
165
+
166
+ tools.push(
111
167
  {
112
168
  name: this._toolName('getData'),
113
- description: getData.getDescription(dataTitle),
169
+ description: `Get current "${dataTitle}" data and validity status. Call this first to see what data already exists.`,
114
170
  inputSchema: getData.inputSchema,
115
171
  outputSchema: getData.outputSchema,
116
172
  execute: async (args) => {
117
173
  try {
118
174
  const result = getData.execute(this._statefulLayout, args || {})
119
175
  return {
120
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
176
+ content: [{ type: 'text', text: JSON.stringify(result) }],
177
+ structuredContent: result
121
178
  }
122
179
  } catch (err) {
123
180
  const message = err instanceof Error ? err.message : String(err)
@@ -130,7 +187,7 @@ export class WebMCP {
130
187
  },
131
188
  {
132
189
  name: this._toolName('setData'),
133
- description: setData.getDescription(dataTitle),
190
+ description: setData.getDescription(dataTitle, complexity),
134
191
  inputSchema: setData.inputSchema,
135
192
  outputSchema: setData.outputSchema,
136
193
  execute: async (args) => {
@@ -138,12 +195,14 @@ export class WebMCP {
138
195
  if (!args?.data) {
139
196
  throw new Error('data is required')
140
197
  }
198
+ args.data = parseIfJsonString(args.data)
141
199
  const result = setData.execute(
142
200
  this._statefulLayout,
143
201
  /** @type {{ data: unknown }} */(args)
144
202
  )
145
203
  return {
146
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
204
+ content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors) }],
205
+ structuredContent: result
147
206
  }
148
207
  } catch (err) {
149
208
  const message = err instanceof Error ? err.message : String(err)
@@ -156,14 +215,16 @@ export class WebMCP {
156
215
  },
157
216
  {
158
217
  name: this._toolName('describeState'),
159
- description: describeState.getDescription(dataTitle),
218
+ description: describeState.getDescription(dataTitle, complexity),
160
219
  inputSchema: describeState.inputSchema,
161
220
  outputSchema: describeState.outputSchema,
162
221
  execute: async (args) => {
163
222
  try {
164
223
  const result = describeState.execute(this._statefulLayout, args || {})
224
+ const text = describeState.toMarkdown(this._statefulLayout, args || {})
165
225
  return {
166
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
226
+ content: [{ type: 'text', text }],
227
+ structuredContent: result
167
228
  }
168
229
  } catch (err) {
169
230
  const message = err instanceof Error ? err.message : String(err)
@@ -184,12 +245,15 @@ export class WebMCP {
184
245
  if (!args?.path) {
185
246
  throw new Error('path is required')
186
247
  }
248
+ args.value = parseIfJsonString(args.value)
187
249
  const result = setFieldValue.execute(
188
250
  this._statefulLayout,
189
251
  /** @type {{ path: string, value: unknown }} */(args)
190
252
  )
253
+ const fieldInfo = `${result.field.path} (${result.field.type}) = ${JSON.stringify(result.field.data)}`
191
254
  return {
192
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
255
+ content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors, fieldInfo) }],
256
+ structuredContent: result
193
257
  }
194
258
  } catch (err) {
195
259
  const message = err instanceof Error ? err.message : String(err)
@@ -202,7 +266,7 @@ export class WebMCP {
202
266
  },
203
267
  {
204
268
  name: this._toolName('getFieldSuggestions'),
205
- description: getFieldSuggestions.getDescription(dataTitle),
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.`,
206
270
  inputSchema: getFieldSuggestions.inputSchema,
207
271
  outputSchema: getFieldSuggestions.outputSchema,
208
272
  execute: async (args) => {
@@ -215,7 +279,41 @@ export class WebMCP {
215
279
  /** @type {{ path: string, query?: string }} */(args)
216
280
  )
217
281
  return {
218
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
282
+ content: [{ type: 'text', text: formatSuggestions(result.items) }],
283
+ structuredContent: result
284
+ }
285
+ } catch (err) {
286
+ const message = err instanceof Error ? err.message : String(err)
287
+ return {
288
+ content: [{ type: 'text', text: `Error: ${message}` }],
289
+ isError: true
290
+ }
291
+ }
292
+ }
293
+ },
294
+ {
295
+ name: this._toolName('editArray'),
296
+ description: editArray.getDescription(dataTitle),
297
+ inputSchema: editArray.inputSchema,
298
+ outputSchema: editArray.outputSchema,
299
+ execute: async (args) => {
300
+ try {
301
+ if (!args?.path || !args?.action) {
302
+ throw new Error('path and action are required')
303
+ }
304
+ if (args.value !== undefined) {
305
+ args.value = parseIfJsonString(args.value)
306
+ }
307
+ const result = editArray.execute(
308
+ this._statefulLayout,
309
+ /** @type {{ path: string, action: 'add'|'remove', index?: number, value?: unknown }} */(args)
310
+ )
311
+ const actionInfo = args.action === 'add'
312
+ ? `added item, ${result.itemCount} total`
313
+ : `removed item, ${result.itemCount} remaining`
314
+ return {
315
+ content: [{ type: 'text', text: formatMutationResult(result.valid, result.errors, actionInfo) }],
316
+ structuredContent: result
219
317
  }
220
318
  } catch (err) {
221
319
  const message = err instanceof Error ? err.message : String(err)
@@ -226,21 +324,42 @@ export class WebMCP {
226
324
  }
227
325
  }
228
326
  }
229
- ]
327
+ )
230
328
 
231
329
  if (this._schema) {
232
330
  tools.push({
233
331
  name: this._toolName('getSchema'),
234
- description: `Get the the JSON schema that governs "${dataTitle}" form.`,
332
+ description: `Get the JSON schema that governs the "${dataTitle}" form.`,
235
333
  outputSchema: {
236
334
  type: 'object',
237
- properties: {
238
- jsonSchema: {},
239
- }
335
+ description: 'The JSON schema definition'
240
336
  },
241
337
  execute: async (args) => {
242
338
  return {
243
- jsonSchema: this._schema
339
+ content: [{ type: 'text', text: JSON.stringify(this._schema) }],
340
+ structuredContent: this._schema
341
+ }
342
+ }
343
+ })
344
+ }
345
+
346
+ if (this._includeSubAgent) {
347
+ const toolNames = tools.map(t => t.name)
348
+ const prompt = fillFormSkill.generateSkill(dataTitle, this._prefixName, !!this._schema, this._statefulLayout)
349
+ tools.push({
350
+ name: `subagent_${this._toolName('form')}`,
351
+ description: `Delegate a form-filling task for "${dataTitle}" to a specialized sub-agent`,
352
+ inputSchema: {
353
+ type: 'object',
354
+ properties: {
355
+ task: { type: 'string', description: 'The task to delegate to this sub-agent' }
356
+ },
357
+ required: ['task']
358
+ },
359
+ execute: async () => {
360
+ return {
361
+ content: [{ type: 'text', text: JSON.stringify({ prompt, tools: toolNames }) }],
362
+ structuredContent: { prompt, tools: toolNames }
244
363
  }
245
364
  }
246
365
  })
@@ -14,6 +14,28 @@ const constraintKeys = {
14
14
  'number-combobox': ['separator']
15
15
  }
16
16
 
17
+ /** @type {Record<string, string>} */
18
+ const compToType = {
19
+ 'text-field': 'text',
20
+ 'number-field': 'number',
21
+ textarea: 'textarea',
22
+ checkbox: 'boolean',
23
+ 'date-picker': 'date',
24
+ 'date-time-picker': 'datetime',
25
+ 'time-picker': 'time',
26
+ select: 'select',
27
+ autocomplete: 'autocomplete',
28
+ combobox: 'combobox',
29
+ 'number-combobox': 'number-combobox',
30
+ 'one-of-select': 'variant-selector',
31
+ list: 'array',
32
+ section: 'section',
33
+ slider: 'slider',
34
+ 'file-input': 'file',
35
+ slot: 'slot',
36
+ 'composite-slot': 'composite-slot'
37
+ }
38
+
17
39
  /**
18
40
  * @param {string} comp
19
41
  * @returns {string[]|undefined}
@@ -24,50 +46,35 @@ function getConstraintKeys (comp) {
24
46
  }
25
47
 
26
48
  /**
27
- * @param {import('../state/types.js').StateNode} node
28
- * @param {import('../state/index.js').StatefulLayout} statefulLayout
29
- * @returns {{
30
- * key: string|number,
49
+ * @typedef {{
31
50
  * path: string,
32
- * comp: string,
51
+ * type: string,
33
52
  * data: unknown,
34
53
  * title?: string,
35
54
  * label?: string,
36
55
  * help?: string,
37
56
  * error?: string,
38
- * childError?: boolean,
39
57
  * required?: boolean,
40
58
  * readOnly?: boolean,
59
+ * modified?: boolean,
41
60
  * constraints?: Record<string, unknown>,
42
- * oneOfItems?: Array<{key: number, title: string}>,
43
- * children?: Array<any>
44
- * getSuffections?: boolean
45
- * }}
61
+ * variants?: Array<{index: number, title: string}>,
62
+ * selectedVariant?: number,
63
+ * children?: Array<ProjectedNode>,
64
+ * getSuggestions?: boolean
65
+ * }} ProjectedNode
66
+ */
67
+
68
+ /**
69
+ * @param {import('../state/types.js').StateNode} node
70
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
71
+ * @returns {ProjectedNode}
46
72
  */
47
73
  export function projectNode (node, statefulLayout) {
48
- /**
49
- * @type {{
50
- * key: string|number,
51
- * path: string,
52
- * comp: string,
53
- * data: unknown,
54
- * title?: string,
55
- * label?: string,
56
- * help?: string,
57
- * error?: string,
58
- * childError?: boolean,
59
- * required?: boolean,
60
- * readOnly?: boolean,
61
- * constraints?: Record<string, unknown>,
62
- * oneOfItems?: Array<{key: number, title: string}>,
63
- * children?: Array<any>
64
- * getSuggestions?: boolean
65
- * }}
66
- */
74
+ /** @type {ProjectedNode} */
67
75
  const out = {
68
- key: node.key,
69
76
  path: node.fullKey,
70
- comp: node.layout.comp,
77
+ type: compToType[node.layout.comp] || node.layout.comp,
71
78
  data: node.data
72
79
  }
73
80
 
@@ -77,10 +84,10 @@ export function projectNode (node, statefulLayout) {
77
84
  if (node.layout.help) out.help = node.layout.help
78
85
 
79
86
  if (node.error) out.error = node.error
80
- if (node.childError) out.childError = true
81
87
 
82
88
  if (node.skeleton.required) out.required = true
83
89
  if (node.options.readOnly) out.readOnly = true
90
+ if (node.modified) out.modified = true
84
91
  if (isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) out.getSuggestions = true
85
92
 
86
93
  const keys = getConstraintKeys(node.layout.comp)
@@ -95,9 +102,12 @@ export function projectNode (node, statefulLayout) {
95
102
  }
96
103
 
97
104
  if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
98
- out.oneOfItems = layout.oneOfItems
105
+ out.variants = layout.oneOfItems
99
106
  .filter((item) => !item.header)
100
- .map((item) => ({ key: item.key, title: item.title }))
107
+ .map((item) => ({ index: item.key, title: item.title }))
108
+ // find selected variant
109
+ const selected = layout.oneOfItems.find((item) => item.selected)
110
+ if (selected) out.selectedVariant = selected.key
101
111
  }
102
112
 
103
113
  if (node.children) {
@@ -109,10 +119,27 @@ export function projectNode (node, statefulLayout) {
109
119
  return out
110
120
  }
111
121
 
122
+ /**
123
+ * Project a single field result for slim mutation responses
124
+ * @param {import('../state/types.js').StateNode} node
125
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
126
+ * @returns {{ path: string, type: string, data: unknown, error?: string }}
127
+ */
128
+ export function projectFieldResult (node, statefulLayout) {
129
+ /** @type {{ path: string, type: string, data: unknown, error?: string }} */
130
+ const out = {
131
+ path: node.fullKey,
132
+ type: compToType[node.layout.comp] || node.layout.comp,
133
+ data: node.data
134
+ }
135
+ if (node.error) out.error = node.error
136
+ return out
137
+ }
138
+
112
139
  /**
113
140
  * @param {import('../state/types.js').StateTree} stateTree
114
141
  * @param {import('../state/index.js').StatefulLayout} statefulLayout
115
- * @returns {{ root: ReturnType<typeof projectNode>, valid: boolean }}
142
+ * @returns {{ root: ProjectedNode, valid: boolean }}
116
143
  */
117
144
  export function projectStateTree (stateTree, statefulLayout) {
118
145
  return {
@@ -121,6 +148,154 @@ export function projectStateTree (stateTree, statefulLayout) {
121
148
  }
122
149
  }
123
150
 
151
+ /**
152
+ * Format a projected node as a markdown line for LLM-readable output.
153
+ * @param {import('../state/types.js').StateNode} node
154
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
155
+ * @param {number} [depth]
156
+ * @returns {string}
157
+ */
158
+ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
159
+ const indent = ' '.repeat(depth)
160
+ const type = compToType[node.layout.comp] || node.layout.comp
161
+ const layout = /** @type {Record<string, unknown>} */(node.layout)
162
+
163
+ // build metadata tags
164
+ const meta = [type]
165
+ if (node.skeleton.required) meta.push('required')
166
+ if (node.options.readOnly) meta.push('readOnly')
167
+ if (node.error) meta.push('error')
168
+ if (node.modified) meta.push('modified')
169
+
170
+ // constraints
171
+ const keys = getConstraintKeys(node.layout.comp)
172
+ if (keys) {
173
+ for (const k of keys) {
174
+ const v = layout[k]
175
+ if (v !== undefined && v !== null) meta.push(`${k}=${v}`)
176
+ }
177
+ }
178
+
179
+ // variants
180
+ if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
181
+ const selected = layout.oneOfItems.find((item) => item.selected)
182
+ if (selected) meta.push(`selected=${selected.key}`)
183
+ }
184
+
185
+ if (isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) meta.push('suggestions')
186
+
187
+ // array item count
188
+ if (node.layout.comp === 'list' && Array.isArray(node.data)) {
189
+ meta.push(`${node.data.length} items`)
190
+ }
191
+
192
+ const path = node.fullKey || '/'
193
+ let line = `${indent}- ${path} (${meta.join(', ')})`
194
+
195
+ if (typeof layout.label === 'string') line += ` label="${layout.label}"`
196
+ else if (typeof layout.title === 'string') line += ` title="${layout.title}"`
197
+
198
+ // value for leaf nodes (no children or empty children)
199
+ if (!node.children || node.children.length === 0) {
200
+ line += ` value=${JSON.stringify(node.data)}`
201
+ }
202
+
203
+ if (node.error) line += ` — ${node.error}`
204
+
205
+ const lines = [line]
206
+
207
+ // variants list
208
+ if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
209
+ const variants = layout.oneOfItems.filter((item) => !item.header)
210
+ for (const v of variants) {
211
+ lines.push(`${indent} - variant ${v.key}: ${v.title}`)
212
+ }
213
+ }
214
+
215
+ // recurse children
216
+ if (node.children) {
217
+ for (const child of node.children) {
218
+ if (child.layout.comp === 'none') continue
219
+ lines.push(projectNodeToMarkdown(child, statefulLayout, depth + 1))
220
+ }
221
+ }
222
+
223
+ return lines.join('\n')
224
+ }
225
+
226
+ /**
227
+ * Format a state tree as markdown for LLM-readable output.
228
+ * @param {import('../state/types.js').StateTree} stateTree
229
+ * @param {import('../state/index.js').StatefulLayout} statefulLayout
230
+ * @returns {string}
231
+ */
232
+ export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
233
+ const errors = collectErrors(stateTree.root)
234
+ const validLine = stateTree.valid
235
+ ? 'valid: true, no errors'
236
+ : `valid: false, ${errors.length} error(s)`
237
+
238
+ const lines = [validLine, '']
239
+
240
+ if (!stateTree.valid && errors.length > 0) {
241
+ lines.push('Errors:')
242
+ for (const e of errors) {
243
+ lines.push(`- ${e.path}: ${e.message}`)
244
+ }
245
+ lines.push('')
246
+ }
247
+
248
+ lines.push('Fields:')
249
+ lines.push(projectNodeToMarkdown(stateTree.root, statefulLayout, 0))
250
+
251
+ return lines.join('\n')
252
+ }
253
+
254
+ /**
255
+ * Format a mutation result as concise text for LLM-readable output.
256
+ * @param {boolean} valid
257
+ * @param {Array<{path: string, message: string}>} errors
258
+ * @param {string} [prefix] - optional prefix line (e.g. field info)
259
+ * @returns {string}
260
+ */
261
+ export function formatMutationResult (valid, errors, prefix) {
262
+ const lines = []
263
+ if (prefix) lines.push(prefix)
264
+
265
+ if (valid) {
266
+ lines.push('valid, no errors')
267
+ } else {
268
+ lines.push(`invalid, ${errors.length} error(s)`)
269
+ if (errors.length > 0) {
270
+ lines.push('Errors:')
271
+ for (const e of errors) {
272
+ lines.push(`- ${e.path}: ${e.message}`)
273
+ }
274
+ }
275
+ }
276
+
277
+ return lines.join('\n')
278
+ }
279
+
280
+ /**
281
+ * Format field suggestions as markdown for LLM-readable output.
282
+ * @param {Array<{value: unknown, title: string, key?: string}>} items
283
+ * @returns {string}
284
+ */
285
+ export function formatSuggestions (items) {
286
+ if (items.length === 0) return 'No suggestions available'
287
+ const lines = ['Suggestions (use the value with setFieldValue or setData):']
288
+ for (const item of items) {
289
+ const val = JSON.stringify(item.value)
290
+ if (item.key && item.key !== item.title) {
291
+ lines.push(`- value=${val} — ${item.title} (${item.key})`)
292
+ } else {
293
+ lines.push(`- value=${val} — ${item.title}`)
294
+ }
295
+ }
296
+ return lines.join('\n')
297
+ }
298
+
124
299
  /**
125
300
  * @param {import('../state/types.js').StateNode} node
126
301
  * @returns {Array<{path: string, message: string}>}
@@ -2,7 +2,7 @@
2
2
  * @file describeState tool
3
3
  */
4
4
 
5
- import { projectStateTree, projectNode, collectErrors } from '../project.js'
5
+ import { projectStateTree, projectNode, collectErrors, projectNodeToMarkdown, projectStateTreeToMarkdown, formatMutationResult } from '../project.js'
6
6
  import { resolveNode } from '../resolve.js'
7
7
 
8
8
  export const inputSchema = {
@@ -40,10 +40,15 @@ export const outputSchema = {
40
40
 
41
41
  /**
42
42
  * @param {string} dataTitle
43
+ * @param {"small"|"medium"|"large"} [complexity]
43
44
  * @returns {string}
44
45
  */
45
- export function getDescription (dataTitle) {
46
- return `Describe the current "${dataTitle}" state tree. Optionally focus on a subtree by path to reduce output size.`
46
+ export function getDescription (dataTitle, complexity) {
47
+ let desc = `Describe the "${dataTitle}" form structure, field types, constraints, and current errors.`
48
+ if (complexity === 'large') {
49
+ desc += ' Use the "path" parameter to focus on a subtree — avoid calling without a path on large forms.'
50
+ }
51
+ return desc
47
52
  }
48
53
 
49
54
  /**
@@ -72,3 +77,23 @@ export function execute (statefulLayout, args) {
72
77
  errors
73
78
  }
74
79
  }
80
+
81
+ /**
82
+ * @param {import('../../state/index.js').StatefulLayout} statefulLayout
83
+ * @param {{ path?: string }} args
84
+ * @returns {string}
85
+ */
86
+ export function toMarkdown (statefulLayout, args) {
87
+ if (args.path) {
88
+ const node = resolveNode(statefulLayout.stateTree.root, args.path)
89
+ if (!node) {
90
+ throw new Error(`node not found at path: ${args.path}`)
91
+ }
92
+ const errors = collectErrors(node)
93
+ return formatMutationResult(statefulLayout.valid, errors,
94
+ projectNodeToMarkdown(node, statefulLayout)
95
+ )
96
+ }
97
+
98
+ return projectStateTreeToMarkdown(statefulLayout.stateTree, statefulLayout)
99
+ }