7c7abe25 by Ean Schuessler

Fix static dropdown options and remove terse mode

- Fix sri.getFieldOptions() call to pass drop-down node instead of parent field node
- Remove terse parameter from all services and serialization
- Increase safety limits by 10x: lists 10K items, strings 1MB, unknown types 10K chars
- Add form-list field metadata extraction in FTL macros
- Simplify McpFieldOptionsService to use CustomScreenTestImpl approach
- Clean up redundant _fixed.groovy file
1 parent a06fd54e
1 arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle 1 arguments=--init-script /home/ean/.config/Code/User/globalStorage/redhat.java/1.51.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle --init-script /home/ean/.config/Code/User/globalStorage/redhat.java/1.51.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/protobuf/init.gradle
2 auto.sync=false 2 auto.sync=false
3 build.scans.enabled=false 3 build.scans.enabled=false
4 connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9)) 4 connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
......
...@@ -143,34 +143,45 @@ ...@@ -143,34 +143,45 @@
143 <#if fieldSubNode["text-line"]?has_content><#assign fieldMeta = fieldMeta + {"type": "text"}></#if> 143 <#if fieldSubNode["text-line"]?has_content><#assign fieldMeta = fieldMeta + {"type": "text"}></#if>
144 <#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if> 144 <#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if>
145 <#if fieldSubNode["drop-down"]?has_content> 145 <#if fieldSubNode["drop-down"]?has_content>
146 <#assign dropdownOptions = sri.getFieldOptions(.node)!> 146 <#-- Get the actual drop-down node (getFieldOptions expects the widget node, not its parent) -->
147 <#if dropdownOptions?has_content> 147 <#assign dropdownNodeList = fieldSubNode["drop-down"]>
148 <#assign fieldMeta = fieldMeta + {"type": "dropdown", "options": dropdownOptions?js_string!}> 148 <#assign dropdownNode = (dropdownNodeList?is_sequence)?then(dropdownNodeList[0], dropdownNodeList)>
149 <#else> 149
150 <#assign dropdownNode = fieldSubNode["drop-down"]!> 150 <#-- Evaluate any 'set' nodes from widget-template-include before getting options -->
151 <#if dropdownNode?is_hash> 151 <#-- These set variables like enumTypeId needed by entity-options -->
152 <#assign dynamicOptionNode = dropdownNode["dynamic-options"][0]!> 152 <#assign setNodes = fieldSubNode["set"]!>
153 <#else> 153 <#list setNodes as setNode>
154 <#assign dynamicOptionNode = dropdownNode[0]["dynamic-options"][0]!> 154 <#if setNode["@field"]?has_content>
155 <#assign dummy = sri.setInContext(setNode)>
155 </#if> 156 </#if>
156 <#if dynamicOptionNode?has_content> 157 </#list>
158 <#-- Get dropdown options - pass the drop-down node, not fieldSubNode -->
159 <#assign dropdownOptions = sri.getFieldOptions(dropdownNode)!>
160 <#if (dropdownOptions?size!0) gt 0>
161 <#-- Build options list from the LinkedHashMap -->
162 <#assign optionsList = []>
163 <#list (dropdownOptions.keySet())! as optKey>
164 <#assign optLabel = (dropdownOptions.get(optKey))!optKey>
165 <#assign optionsList = optionsList + [{"value": optKey, "label": optLabel}]>
166 </#list>
167 <#assign fieldMeta = fieldMeta + {"type": "dropdown", "options": optionsList}>
168 <#else>
169 <#-- No static options - check for dynamic-options -->
170 <#assign dynamicOptionsList = dropdownNode["dynamic-options"]!>
171
172 <#if dynamicOptionsList?has_content && dynamicOptionsList?size gt 0>
173 <#assign dynamicOptionNode = dynamicOptionsList[0]>
174
157 <#-- Try to extract transition metadata for better autocomplete support --> 175 <#-- Try to extract transition metadata for better autocomplete support -->
158 <#assign transitionMetadata = {}> 176 <#assign transitionMetadata = {}>
159 <#if dynamicOptionNode["@transition"]?has_content> 177 <#if dynamicOptionNode["@transition"]?has_content>
160 <#assign transitionNode = sri.getScreenDefinition().getTransitionItem(dynamicOptionNode["@transition"]!"")!> 178 <#assign activeScreenDef = sri.getActiveScreenDef()!>
161 <#if transitionNode?has_content> 179 <#if activeScreenDef?has_content>
162 <#-- Extract service name if present --> 180 <#assign transitionItem = activeScreenDef.getTransitionItem(dynamicOptionNode["@transition"]!"", null)!>
163 <#assign serviceCallNode = transitionNode["service-call"][0]!> 181 <#if transitionItem?has_content>
164 <#if serviceCallNode?has_content && serviceCallNode["@name"]?has_content> 182 <#assign serviceName = transitionItem.getSingleServiceName()!"">
165 <#assign transitionMetadata = transitionMetadata + {"serviceName": (serviceCallNode["@name"]!"")}> 183 <#if serviceName?has_content && serviceName != "">
166 </#if> 184 <#assign transitionMetadata = transitionMetadata + {"serviceName": serviceName}>
167 <#-- Extract in-map parameter mapping -->
168 <#if serviceCallNode["@in-map"]?has_content>
169 <#assign transitionMetadata = transitionMetadata + {"inParameterMap": ((serviceCallNode["@in-map"]!"")?js_string)!""}>
170 <#elseif transitionNode["parameter"]?has_content>
171 <#assign paramNode = transitionNode["parameter"][0]!>
172 <#if paramNode?has_content>
173 <#assign transitionMetadata = transitionMetadata + {"inParameterMap": "[]"}>
174 </#if> 185 </#if>
175 </#if> 186 </#if>
176 </#if> 187 </#if>
...@@ -237,9 +248,9 @@ ...@@ -237,9 +248,9 @@
237 <#assign formNode = formListInfo.getFormNode()> 248 <#assign formNode = formListInfo.getFormNode()>
238 <#assign formListColumnList = formListInfo.getAllColInfo()> 249 <#assign formListColumnList = formListInfo.getAllColInfo()>
239 <#assign listObject = formListInfo.getListObject(false)!> 250 <#assign listObject = formListInfo.getListObject(false)!>
240 <#assign totalItems = listObject?size> 251 <#assign totalItems = (listObject?size)!0>
241 252
242 <#if mcpSemanticData?? && listObject?has_content> 253 <#if mcpSemanticData??>
243 <#assign formName = (.node["@name"]!"")?string> 254 <#assign formName = (.node["@name"]!"")?string>
244 <#assign displayedItems = (totalItems > 50)?then(50, totalItems)> 255 <#assign displayedItems = (totalItems > 50)?then(50, totalItems)>
245 <#assign isTruncated = (totalItems > 50)> 256 <#assign isTruncated = (totalItems > 50)>
...@@ -248,9 +259,99 @@ ...@@ -248,9 +259,99 @@
248 <#assign fieldNode = columnFieldList[0]> 259 <#assign fieldNode = columnFieldList[0]>
249 <#assign columnNames = columnNames + [fieldNode["@name"]!""]> 260 <#assign columnNames = columnNames + [fieldNode["@name"]!""]>
250 </#list> 261 </#list>
262
263 <#-- Extract Field Metadata for form-list (header fields usually) -->
264 <#assign fieldMetaList = []>
265 <#list formListColumnList as columnFieldList>
266 <#assign fieldNode = columnFieldList[0]>
267 <#assign fieldSubNode = fieldNode["header-field"][0]!fieldNode["default-field"][0]!fieldNode["conditional-field"][0]!>
268
269 <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content>
270 <#assign title><@fieldTitle fieldSubNode/></#assign>
271 <#assign fieldMeta = {"name": (fieldNode["@name"]!""), "title": (title!), "required": (fieldNode["@required"]! == "true")}>
272
273 <#if fieldSubNode["text-line"]?has_content><#assign fieldMeta = fieldMeta + {"type": "text"}></#if>
274 <#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if>
275 <#if fieldSubNode["drop-down"]?has_content>
276 <#-- Evaluate any 'set' nodes from widget-template-include before getting options -->
277 <#list fieldSubNode["set"]! as setNode>
278 <#if setNode["@field"]?has_content>
279 <#assign dummy = sri.setInContext(setNode)>
280 </#if>
281 </#list>
282 <#assign dropdownOptions = sri.getFieldOptions(fieldSubNode)!>
283 <#if dropdownOptions?has_content && dropdownOptions?size gt 0>
284 <#-- Convert LinkedHashMap<String,String> to list of {value, label} objects for JSON -->
285 <#assign optionsList = []>
286 <#list dropdownOptions?keys as optKey>
287 <#assign optionsList = optionsList + [{"value": optKey, "label": dropdownOptions[optKey]!optKey}]>
288 </#list>
289 <#assign fieldMeta = fieldMeta + {"type": "dropdown", "options": optionsList}>
290 <#else>
291 <#assign dropdownNode = fieldSubNode["drop-down"]!>
292
293 <#-- Robust dynamic-options extraction -->
294 <#assign actualDropdown = (dropdownNode?is_sequence)?then(dropdownNode[0]!dropdownNode, dropdownNode)>
295 <#assign dynamicOptionsList = actualDropdown["dynamic-options"]!>
296
297 <#if dynamicOptionsList?has_content && dynamicOptionsList?size gt 0>
298 <#assign dynamicOptionNode = dynamicOptionsList[0]>
299
300 <#-- Try to extract transition metadata for better autocomplete support -->
301 <#assign transitionMetadata = {}>
302 <#if dynamicOptionNode["@transition"]?has_content>
303 <#assign activeScreenDef = sri.getActiveScreenDef()!>
304 <#if activeScreenDef?has_content>
305 <#assign transitionItem = activeScreenDef.getTransitionItem(dynamicOptionNode["@transition"]!"", null)!>
306 <#if transitionItem?has_content>
307 <#assign serviceName = transitionItem.getSingleServiceName()!"">
308 <#if serviceName?has_content && serviceName != "">
309 <#assign transitionMetadata = transitionMetadata + {"serviceName": serviceName}>
310 </#if>
311 </#if>
312 </#if>
313 </#if>
314 <#assign dependsOnList = []>
315 <#list dynamicOptionNode["depends-on"]! as depNode>
316 <#assign depField = depNode["@field"]!"">
317 <#assign depParameter = depNode["@parameter"]!depField>
318 <#assign dependsOnItem = depField + "|" + depParameter>
319 <#assign dependsOnList = dependsOnList + [dependsOnItem]>
320 </#list>
321 <#assign dependsOnJson = '[]'>
322 <#if dependsOnList?size gt 0>
323 <#assign dependsOnJson = '['>
324 <#list dependsOnList as dep><#if dep_index gt 0><#assign dependsOnJson = dependsOnJson + ', '></#if><#assign dependsOnJson = dependsOnJson + '"' + dep + '"'></#list>
325 <#assign dependsOnJson = dependsOnJson + ']'>
326 </#if>
327 <#assign fieldMeta = fieldMeta + {"type": "dropdown", "dynamicOptions": {
328 "transition": (dynamicOptionNode["@transition"]!""),
329 "serverSearch": (dynamicOptionNode["@server-search"]! == "true"),
330 "minLength": (dynamicOptionNode["@min-length"]!"0"),
331 "parameterMap": ((dynamicOptionNode["@parameter-map"]!"")?js_string)!"",
332 "dependsOn": dependsOnJson
333 } + transitionMetadata}>
334 <#else>
335 <#assign fieldMeta = fieldMeta + {"type": "dropdown"}>
336 </#if>
337 </#if>
338 </#if>
339 <#if fieldSubNode["check"]?has_content><#assign fieldMeta = fieldMeta + {"type": "checkbox"}></#if>
340 <#if fieldSubNode["radio"]?has_content><#assign fieldMeta = fieldMeta + {"type": "radio"}></#if>
341 <#if fieldSubNode["date-find"]?has_content><#assign fieldMeta = fieldMeta + {"type": "date"}></#if>
342 <#if fieldSubNode["display"]?has_content || fieldSubNode["display-entity"]?has_content><#assign fieldMeta = fieldMeta + {"type": "display"}></#if>
343 <#if fieldSubNode["link"]?has_content><#assign fieldMeta = fieldMeta + {"type": "link"}></#if>
344 <#if fieldSubNode["file"]?has_content><#assign fieldMeta = fieldMeta + {"type": "file-upload"}></#if>
345 <#if fieldSubNode["hidden"]?has_content><#assign fieldMeta = fieldMeta + {"type": "hidden"}></#if>
346
347 <#assign fieldMetaList = fieldMetaList + [fieldMeta]>
348 </#if>
349 </#list>
350
251 <#assign dummy = ec.context.put("tempListObject", listObject)!> 351 <#assign dummy = ec.context.put("tempListObject", listObject)!>
252 <#assign dummy = ec.context.put("tempColumnNames", columnNames)!> 352 <#assign dummy = ec.context.put("tempColumnNames", columnNames)!>
253 <#assign dummy = ec.resource.expression("mcpSemanticData.put('" + formName?js_string + "', tempListObject); if (mcpSemanticData.listMetadata == null) mcpSemanticData.listMetadata = [:]; mcpSemanticData.listMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', totalItems: " + totalItems + ", displayedItems: " + displayedItems + ", truncated: " + isTruncated?string + ", columns: tempColumnNames])", "")!> 353 <#assign dummy = ec.context.put("tempFieldMetaList", fieldMetaList)!>
354 <#assign dummy = ec.resource.expression("mcpSemanticData.put('" + formName?js_string + "', tempListObject); if (mcpSemanticData.formMetadata == null) mcpSemanticData.formMetadata = [:]; mcpSemanticData.formMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', type: 'form-list', map: '', fields: tempFieldMetaList]); if (mcpSemanticData.listMetadata == null) mcpSemanticData.listMetadata = [:]; mcpSemanticData.listMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', totalItems: " + totalItems + ", displayedItems: " + displayedItems + ", truncated: " + isTruncated?string + ", columns: tempColumnNames])", "")!>
254 </#if> 355 </#if>
255 356
256 <#-- Header Row --> 357 <#-- Header Row -->
......
...@@ -17,7 +17,6 @@ warranty. ...@@ -17,7 +17,6 @@ warranty.
17 <parameter name="parameters" type="Map"/> 17 <parameter name="parameters" type="Map"/>
18 <parameter name="renderMode" default="mcp"/> 18 <parameter name="renderMode" default="mcp"/>
19 <parameter name="sessionId"/> 19 <parameter name="sessionId"/>
20 <parameter name="terse" type="Boolean" default="false"/>
21 </in-parameters> 20 </in-parameters>
22 <out-parameters> 21 <out-parameters>
23 <parameter name="result" type="Map"/> 22 <parameter name="result" type="Map"/>
...@@ -48,8 +47,7 @@ warranty. ...@@ -48,8 +47,7 @@ warranty.
48 path: screenPath, 47 path: screenPath,
49 parameters: parameters ?: [:], 48 parameters: parameters ?: [:],
50 renderMode: renderMode ?: "mcp", 49 renderMode: renderMode ?: "mcp",
51 sessionId: sessionId, 50 sessionId: sessionId
52 terse: terse == true
53 ] 51 ]
54 if (subscreenName) screenCallParams.subscreenName = subscreenName 52 if (subscreenName) screenCallParams.subscreenName = subscreenName
55 53
...@@ -86,8 +84,7 @@ warranty. ...@@ -86,8 +84,7 @@ warranty.
86 uiNarrative = narrativeBuilder.buildNarrative( 84 uiNarrative = narrativeBuilder.buildNarrative(
87 screenDefForNarrative, 85 screenDefForNarrative,
88 semanticState, 86 semanticState,
89 path, 87 path
90 terse == true
91 ) 88 )
92 ec.logger.info("RenderScreenNarrative: Generated UI narrative for ${path}") 89 ec.logger.info("RenderScreenNarrative: Generated UI narrative for ${path}")
93 } catch (Exception e) { 90 } catch (Exception e) {
......
...@@ -569,7 +569,6 @@ ...@@ -569,7 +569,6 @@
569 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> 569 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter>
570 <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> 570 <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter>
571 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> 571 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter>
572 <parameter name="terse" type="Boolean" default="false"><description>If true, return condensed data (50 items, 5000 char strings). If false, include full data (1000 items, 50k char strings).</description></parameter>
573 </in-parameters> 572 </in-parameters>
574 <out-parameters> 573 <out-parameters>
575 <parameter name="result" type="Map"/> 574 <parameter name="result" type="Map"/>
...@@ -639,11 +638,11 @@ def getWikiInstructions = { lookupPath -> ...@@ -639,11 +638,11 @@ def getWikiInstructions = { lookupPath ->
639 return null 638 return null
640 } 639 }
641 640
642 // Optimized recursive serializer for Moqui/Java objects to JSON-friendly Map/List 641 // Recursive serializer for Moqui/Java objects to JSON-friendly Map/List
643 // When terse=true, returns minimal data with truncation metadata for easy access to full version 642 // Applies reasonable safety limits to prevent massive payloads
644 def serializeMoquiObject 643 def serializeMoquiObject
645 serializeMoquiObject = { obj, depth = 0, isTerse = false -> 644 serializeMoquiObject = { obj, depth = 0 ->
646 if (depth > 5) return "..." // Prevent deep recursion 645 if (depth > 8) return "..." // Prevent deep recursion
647 646
648 if (obj == null) return null 647 if (obj == null) return null
649 if (obj instanceof Map) { 648 if (obj instanceof Map) {
...@@ -654,44 +653,31 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> ...@@ -654,44 +653,31 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
654 if (keyStr.startsWith("_") || keyStr == "ec" || keyStr == "sri") return 653 if (keyStr.startsWith("_") || keyStr == "ec" || keyStr == "sri") return
655 // Skip audit fields to reduce payload 654 // Skip audit fields to reduce payload
656 if (keyStr in ["lastUpdatedStamp", "lastUpdatedTxStamp", "createdDate", "createdTxStamp", "createdByUserLogin"]) return 655 if (keyStr in ["lastUpdatedStamp", "lastUpdatedTxStamp", "createdDate", "createdTxStamp", "createdByUserLogin"]) return
657 def value = serializeMoquiObject(v, depth + 1, isTerse) 656 def value = serializeMoquiObject(v, depth + 1)
658 if (value != null) newMap[keyStr] = value 657 if (value != null) newMap[keyStr] = value
659 } 658 }
660 return newMap 659 return newMap
661 } 660 }
662 if (obj instanceof Iterable) { 661 if (obj instanceof Iterable) {
663 def list = obj.collect() 662 def list = obj.collect()
664 // Apply truncation only if terse mode is enabled 663 // Safety limit: truncate very large lists (10000+ items)
665 if (isTerse && list.size() > 50) { 664 def maxItems = 10000
666 ec.logger.info("serializeMoquiObject: Terse mode - truncating list from ${list.size()} to 50 items") 665 if (list.size() > maxItems) {
667 def truncated = list.take(50) 666 ec.logger.info("serializeMoquiObject: Truncating large list from ${list.size()} to ${maxItems} items")
668 def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
669 return [
670 _items: resultList,
671 _totalCount: list.size(),
672 _truncated: true,
673 _hasMore: true,
674 _message: "Terse mode: showing first 50 of ${list.size()} items. Set terse=false to get full data."
675 ]
676 }
677 // Increased limits for non-terse mode - effectively unlimited for operational use
678 def maxItems = isTerse ? 50 : 1000
679 def truncated = list.take(maxItems) 667 def truncated = list.take(maxItems)
680 def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) } 668 def resultList = truncated.collect { serializeMoquiObject(it, depth + 1) }
681 if (!isTerse && list.size() > maxItems) {
682 ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to ${maxItems} items for safety")
683 return [ 669 return [
684 _items: resultList, 670 _items: resultList,
685 _totalCount: list.size(), 671 _totalCount: list.size(),
686 _truncated: true, 672 _truncated: true,
687 _hasMore: true, 673 _hasMore: true,
688 _message: "Truncated to ${maxItems} items (size safety limit reached)" 674 _message: "Showing first ${maxItems} of ${list.size()} items. Use pagination for more."
689 ] 675 ]
690 } 676 }
691 return resultList 677 return list.collect { serializeMoquiObject(it, depth + 1) }
692 } 678 }
693 if (obj instanceof org.moqui.entity.EntityValue) { 679 if (obj instanceof org.moqui.entity.EntityValue) {
694 return serializeMoquiObject(obj.getMap(), depth + 1, isTerse) 680 return serializeMoquiObject(obj.getMap(), depth + 1)
695 } 681 }
696 if (obj instanceof java.sql.Timestamp || obj instanceof java.util.Date) { 682 if (obj instanceof java.sql.Timestamp || obj instanceof java.util.Date) {
697 return obj.toString() 683 return obj.toString()
...@@ -700,22 +686,13 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> ...@@ -700,22 +686,13 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
700 return obj 686 return obj
701 } 687 }
702 if (obj instanceof String) { 688 if (obj instanceof String) {
703 // Apply truncation only if terse mode is enabled 689 // Safety limit: truncate very large strings (1MB+)
704 if (isTerse && obj.length() > 5000) { 690 if (obj.length() > 1000000) {
705 return [ 691 return [
706 _value: obj.substring(0, 5000) + "...", 692 _value: obj.substring(0, 1000000) + "...",
707 _fullLength: obj.length(), 693 _fullLength: obj.length(),
708 _truncated: true, 694 _truncated: true,
709 _message: "Terse mode: truncated to 5000 chars. Set terse=false to get more data." 695 _message: "Truncated to 1MB for safety."
710 ]
711 }
712 // Non-terse limit increased to 50000 for safety
713 if (!isTerse && obj.length() > 50000) {
714 return [
715 _value: obj.substring(0, 50000) + "...",
716 _fullLength: obj.length(),
717 _truncated: true,
718 _message: "Truncated to 50000 chars for safety."
719 ] 696 ]
720 } 697 }
721 return obj 698 return obj
...@@ -727,11 +704,11 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> ...@@ -727,11 +704,11 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
727 if (obj instanceof org.moqui.entity.EntityFind) { 704 if (obj instanceof org.moqui.entity.EntityFind) {
728 return null 705 return null
729 } 706 }
730 // Fallback for unknown types - truncate if too long 707 // Fallback for unknown types
731 def str = obj.toString() 708 def str = obj.toString()
732 if (str.length() > 200) { 709 if (str.length() > 10000) {
733 return [ 710 return [
734 _value: str.substring(0, 200) + "...", 711 _value: str.substring(0, 10000) + "...",
735 _fullLength: str.length(), 712 _fullLength: str.length(),
736 _truncated: true 713 _truncated: true
737 ] 714 ]
...@@ -892,12 +869,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -892,12 +869,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
892 // Check for errors in execution context after service call 869 // Check for errors in execution context after service call
893 def hasError = ec.message.hasError() 870 def hasError = ec.message.hasError()
894 871
895 // Flush transaction to ensure data is committed
896 ec.getTransaction().flush()
897
898 // Check again after flush - this catches constraint violations that occur during flush
899 hasError = hasError || ec.message.hasError()
900
901 if (hasError) { 872 if (hasError) {
902 actionResult = [ 873 actionResult = [
903 action: action, 874 action: action,
...@@ -949,24 +920,22 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -949,24 +920,22 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
949 def testRender = screenTest.render(relativePath, renderParams, "POST") 920 def testRender = screenTest.render(relativePath, renderParams, "POST")
950 output = testRender.getOutput() 921 output = testRender.getOutput()
951 922
952 // --- NEW: Semantic State Extraction --- 923 // --- Semantic State Extraction ---
953 def postContext = testRender.getPostRenderContext() 924 def postContext = testRender.getPostRenderContext()
954 def semanticState = [:] 925 def semanticState = [:]
955 def isTerse = context.terse == true
956 926
957 // Get final screen definition using resolved screen location 927 // Get final screen definition using resolved screen location
958 def finalScreenDef = resolvedScreenDef 928 def finalScreenDef = resolvedScreenDef
959 929
960 if (finalScreenDef && postContext) { 930 if (finalScreenDef && postContext) {
961 semanticState.screenPath = inputScreenPath 931 semanticState.screenPath = inputScreenPath
962 semanticState.terse = isTerse
963 semanticState.data = [:] 932 semanticState.data = [:]
964 933
965 // Use the explicit semantic data captured by macros if available 934 // Use the explicit semantic data captured by macros if available
966 def explicitData = postContext.get("mcpSemanticData") 935 def explicitData = postContext.get("mcpSemanticData")
967 if (explicitData instanceof Map) { 936 if (explicitData instanceof Map) {
968 explicitData.each { k, v -> 937 explicitData.each { k, v ->
969 semanticState.data[k] = serializeMoquiObject(v, 0, isTerse) 938 semanticState.data[k] = serializeMoquiObject(v, 0)
970 } 939 }
971 } 940 }
972 941
...@@ -1008,7 +977,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1008,7 +977,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1008 977
1009 // Add value if exists 978 // Add value if exists
1010 if (value != null) { 979 if (value != null) {
1011 paramInfo.value = serializeMoquiObject(value, 0, isTerse) 980 paramInfo.value = serializeMoquiObject(value, 0)
1012 } 981 }
1013 982
1014 // Extract parameter type - try multiple approaches 983 // Extract parameter type - try multiple approaches
...@@ -1064,7 +1033,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1064,7 +1033,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1064 // Log semantic state size for optimization tracking 1033 // Log semantic state size for optimization tracking
1065 def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString() 1034 def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString()
1066 def semanticStateSize = semanticStateJson.length() 1035 def semanticStateSize = semanticStateJson.length()
1067 ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}") 1036 ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}")
1068 } 1037 }
1069 1038
1070 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}") 1039 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
...@@ -1088,13 +1057,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1088,13 +1057,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1088 mcpResult.actionResult = actionResult 1057 mcpResult.actionResult = actionResult
1089 } 1058 }
1090 1059
1091 // Truncate text preview only if terse=true 1060 // Include text output preview (truncated for readability)
1092 if (output) { 1061 if (output) {
1093 if (isTerse) { 1062 mcpResult.textPreview = output.take(2000) + (output.length() > 2000 ? "..." : "")
1094 mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "")
1095 } else {
1096 mcpResult.textPreview = output
1097 }
1098 } 1063 }
1099 if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions 1064 if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions
1100 1065
...@@ -1414,7 +1379,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1414,7 +1379,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1414 <parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter> 1379 <parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter>
1415 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter> 1380 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter>
1416 <parameter name="sessionId"/> 1381 <parameter name="sessionId"/>
1417 <parameter name="terse" type="Boolean" default="false"><description>If true, return condensed data (50 items, 5000 char strings). If false, include full data (1000 items, 50k char strings).</description></parameter>
1418 </in-parameters> 1382 </in-parameters>
1419 <out-parameters> 1383 <out-parameters>
1420 <parameter name="result" type="Map"/> 1384 <parameter name="result" type="Map"/>
...@@ -1749,8 +1713,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1749,8 +1713,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1749 path: path, 1713 path: path,
1750 parameters: parameters ?: [:], 1714 parameters: parameters ?: [:],
1751 renderMode: actualRenderMode, 1715 renderMode: actualRenderMode,
1752 sessionId: sessionId, 1716 sessionId: sessionId
1753 terse: context.terse == true
1754 ] 1717 ]
1755 1718
1756 // Call ScreenAsMcpTool to render 1719 // Call ScreenAsMcpTool to render
...@@ -1785,8 +1748,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1785,8 +1748,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1785 def uiNarrative = narrativeBuilder.buildNarrative( 1748 def uiNarrative = narrativeBuilder.buildNarrative(
1786 screenDefForNarrative, 1749 screenDefForNarrative,
1787 resultObj.semanticState, 1750 resultObj.semanticState,
1788 currentPath, 1751 currentPath
1789 context.terse == true
1790 ) 1752 )
1791 resultMap.uiNarrative = uiNarrative 1753 resultMap.uiNarrative = uiNarrative
1792 ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}: ${uiNarrative?.keySet()}") 1754 ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}: ${uiNarrative?.keySet()}")
...@@ -1912,8 +1874,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1912,8 +1874,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1912 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], 1874 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"],
1913 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], 1875 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"],
1914 "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"], 1876 "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"],
1915 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"], 1877 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"]
1916 "terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"]
1917 ] 1878 ]
1918 ] 1879 ]
1919 ], 1880 ],
......
1 package org.moqui.mcp 1 package org.moqui.mcp
2 2
3 import org.moqui.context.ExecutionContext 3 import org.moqui.context.ExecutionContext
4 4 import org.moqui.impl.context.ExecutionContextFactoryImpl
5 import groovy.json.JsonSlurper
6
7 /**
8 * Service for getting screen field details including dropdown options via dynamic-options.
9 *
10 * This implementation mirrors how the Moqui web UI handles autocomplete:
11 * - Uses CustomScreenTestImpl with skipJsonSerialize(true) to call transitions
12 * - Captures the raw JSON response via getJsonObject()
13 * - Processes the response to extract options
14 *
15 * See ScreenRenderImpl.getFieldOptions() in moqui-framework for the reference implementation.
16 */
5 class McpFieldOptionsService { 17 class McpFieldOptionsService {
6 static service(String path, String fieldName, Map parameters, ExecutionContext ec) {
7 if (!path) {
8 throw new IllegalArgumentException("path is required")
9 }
10
11 ec.logger.info("MCP GetScreenDetails: Getting details for screen ${path}, field ${fieldName ?: 'all'}")
12 18
13 def result = [ 19 static service(String path, String fieldName, Map parameters, ExecutionContext ec) {
14 screenPath: path, 20 ec.logger.info("======== MCP GetScreenDetails CALLED - CODE VERSION 3 (ScreenTest) =======")
15 fields: [:] 21 if (!path) throw new IllegalArgumentException("path is required")
16 ] 22 ec.logger.info("MCP GetScreenDetails: screen ${path}, field ${fieldName ?: 'all'}")
17 23
24 def result = [screenPath: path, fields: [:]]
18 try { 25 try {
19 // First, render screen to get form metadata (including dynamicOptions)
20 def browseScreenCallParams = [
21 path: path,
22 parameters: parameters ?: [:],
23 renderMode: "mcp",
24 sessionId: null,
25 terse: false
26 ]
27
28 def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") 26 def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
29 .parameters(browseScreenCallParams) 27 .parameters([path: path, parameters: parameters ?: [:], renderMode: "mcp", sessionId: null])
30 .call() 28 .call()
31 29
32 if (browseResult?.result?.content?.size() > 0) { 30 ec.logger.info("=== browseResult: ${browseResult != null}, result exists: ${browseResult?.result != null} ===")
31
32 if (!browseResult?.result?.content) {
33 ec.logger.warn("No content from ScreenAsMcpTool")
34 return result + [error: "No content from ScreenAsMcpTool"]
35 }
33 def rawText = browseResult.result.content[0].text 36 def rawText = browseResult.result.content[0].text
34 if (rawText && rawText.startsWith("{")) { 37 if (!rawText || !rawText.startsWith("{")) {
35 def resultObj = new groovy.json.JsonSlurper().parseText(rawText) 38 ec.logger.warn("Invalid JSON from ScreenAsMcpTool")
36 def semanticData = resultObj?.semanticState?.data 39 return result + [error: "Invalid JSON from ScreenAsMcpTool"]
40 }
37 41
38 if (semanticData?.containsKey("formMetadata")) { 42 def resultObj = new JsonSlurper().parseText(rawText)
39 def formMetadata = semanticData.formMetadata 43 def semanticState = resultObj?.semanticState
40 def allFields = [:] 44 def formMetadata = semanticState?.data?.formMetadata
45
46 if (!(formMetadata instanceof Map)) {
47 ec.logger.warn("formMetadata is not a Map: ${formMetadata?.class}")
48 return result + [error: "No form metadata found"]
49 }
41 50
42 if (formMetadata instanceof Map) { 51 def allFields = [:]
52 ec.logger.info("=== Processing formMetadata with ${formMetadata.size()} forms ===")
43 formMetadata.each { formName, formItem -> 53 formMetadata.each { formName, formItem ->
44 if (formItem instanceof Map && formItem.containsKey("fields")) { 54 ec.logger.info("=== Processing form: ${formName}, hasFields: ${formItem?.fields != null} ===")
45 def fieldList = formItem.fields 55 if (!(formItem instanceof Map) || !formItem.fields) return
46 if (fieldList instanceof Collection) { 56 formItem.fields.each { field ->
47 fieldList.each { field -> 57 if (!(field instanceof Map) || !field.name) return
48 if (field instanceof Map && field.containsKey("name")) { 58
49 def fieldInfo = [ 59 def fieldInfo = [
50 name: field.name, 60 name: field.name,
51 title: field.title, 61 title: field.title,
52 type: field.type, 62 type: field.type,
53 required: field.required ?: false 63 required: field.required ?: false
54 ] 64 ]
65 if (field.type == "dropdown" && field.options) fieldInfo.options = field.options
55 66
56 // Add dropdown options if available (static options)
57 if (field.type == "dropdown" && field.containsKey("options")) {
58 fieldInfo.options = field.options
59 }
60
61 // Add dynamic options metadata and actually fetch the options
62 def skipField = false
63 if (field.containsKey("dynamicOptions") && !skipField) {
64 def dynamicOptions = field.dynamicOptions 67 def dynamicOptions = field.dynamicOptions
68 if (dynamicOptions instanceof Map) {
65 fieldInfo.dynamicOptions = dynamicOptions 69 fieldInfo.dynamicOptions = dynamicOptions
66 70 ec.logger.info("Found dynamicOptions for field ${field.name}: ${dynamicOptions}")
67 try { 71 try {
68 def serviceName = dynamicOptions.containsKey("service") ? dynamicOptions.service : null 72 fetchOptions(fieldInfo, path, parameters, dynamicOptions, ec)
69 def transitionName = dynamicOptions.containsKey("transition") ? dynamicOptions.transition : null 73 } catch (Exception e) {
70 def optionParams = [:] 74 ec.logger.warn("Failed to fetch options for ${field.name}: ${e.message}", e)
71 75 fieldInfo.optionsError = e.message
72 // Parse inParameterMap if specified (extracted from transition XML)
73 def inParameterMap = [:]
74 if (dynamicOptions.containsKey("inParameterMap") && dynamicOptions.inParameterMap && dynamicOptions.inParameterMap.trim()) {
75 // Parse in-map format: "[target1:source1,target2:source2]"
76 def mapContent = dynamicOptions.inParameterMap.trim()
77 if (mapContent.startsWith("[") && mapContent.endsWith("]")) {
78 def innerContent = mapContent.substring(1, mapContent.length() - 1)
79 innerContent.split(',').each { mapping ->
80 def colonIndex = mapping.indexOf(':')
81 if (colonIndex > 0) {
82 def targetParam = mapping.substring(0, colonIndex).trim()
83 def sourceFields = mapping.substring(colonIndex + 1).trim()
84 // Handle multiple source fields separated by comma
85 sourceFields.split(',').each { sourceField ->
86 def sourceValue = parameters?.get(sourceField.trim())
87 if (sourceValue != null) {
88 inParameterMap[targetParam] = sourceValue
89 ec.logger.info("MCP GetScreenDetails: Mapped in-param ${sourceField} -> ${targetParam} = ${sourceValue}")
90 }
91 }
92 }
93 }
94 }
95 }
96
97 // Handle depends-on fields and parameter overrides
98 ec.logger.info("MCP GetScreenDetails: Processing depends-on for field ${field.name}")
99
100 // Parse depends-on list (may include parameter overrides like "field|parameter")
101 def dependsOnFields = []
102 if (dynamicOptions.containsKey("dependsOn") && dynamicOptions.dependsOn) {
103 if (dynamicOptions.dependsOn instanceof String) {
104 dependsOnFields = new groovy.json.JsonSlurper().parseText(dynamicOptions.dependsOn)
105 } else if (dynamicOptions.dependsOn instanceof List) {
106 dependsOnFields = dynamicOptions.dependsOn
107 }
108 }
109
110 // Process each depends-on field with potential parameter override
111 dependsOnFields.each { depFieldOrTuple ->
112 def depField = depFieldOrTuple
113 def depParameter = depFieldOrTuple // Default: use field name as parameter name
114
115 // Check if depends-on item is a "field|parameter" tuple
116 if (depFieldOrTuple instanceof String && depFieldOrTuple.contains("|")) {
117 def parts = depFieldOrTuple.split("\\|")
118 if (parts.size() == 2) {
119 depField = parts[0].trim()
120 depParameter = parts[1].trim()
121 }
122 }
123
124 def depValue = parameters?.get(depField)
125 if (depValue == null) {
126 ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} has no value in parameters")
127 } else {
128 ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} = ${depValue}, targetParam = ${depParameter}")
129 // Add to optionParams - use depParameter as key if specified, otherwise use depField
130 optionParams[depParameter ?: depField] = depValue
131 }
132 }
133
134 // For transitions with web-send-json-response, try to extract and call service directly
135 // These transitions wrap services like BasicServices.get#GeoRegionsForDropDown
136 if (dynamicOptions.containsKey("serviceName") && dynamicOptions.serviceName) {
137 // Direct service call - use extracted service name from transition XML
138 ec.logger.info("MCP GetScreenDetails: Calling direct service ${dynamicOptions.serviceName} for field ${field.name} with optionParams: ${optionParams}")
139 def optionsResult = ec.service.sync().name(dynamicOptions.serviceName).parameters(optionParams).call()
140 if (optionsResult && optionsResult.resultList) {
141 def optionsList = []
142 optionsResult.resultList.each { opt ->
143 if (opt instanceof Map) {
144 def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
145 def label = opt.label ?: opt.description ?: opt.value
146 optionsList << [value: key, label: label]
147 } 76 }
148 } 77 }
149 if (optionsList) {
150 fieldInfo.options = optionsList
151 ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call")
152 allFields[field.name] = fieldInfo 78 allFields[field.name] = fieldInfo
153 return // Skip remaining processing for this field
154 } 79 }
155 } 80 }
81
82 if (fieldName) {
83 if (allFields[fieldName]) result.fields[fieldName] = allFields[fieldName]
84 else result.error = "Field not found: ${fieldName}"
156 } else { 85 } else {
157 // Fallback for hardcoded transitions or when serviceName not available 86 result.fields = allFields.collectEntries { k, v -> [k, v] }
158 ec.logger.info("MCP GetScreenDetails: No serviceName found, checking hardcoded transitions")
159 if (transitionName == "getGeoCountryStates" || transitionName == "getGeoStateCounties") {
160 def underlyingService = "org.moqui.impl.BasicServices.get#GeoRegionsForDropDown"
161 // Map depends-on field names to service parameter names (e.g., countryGeoId -> geoId)
162 def serviceParams = [:]
163 if (optionParams.containsKey("countryGeoId")) {
164 serviceParams.geoId = optionParams.countryGeoId
165 }
166 if (optionParams.containsKey("stateGeoId")) {
167 serviceParams.geoId = optionParams.stateGeoId
168 serviceParams.geoTypeEnumId = "GEOT_COUNTY"
169 }
170 if (optionParams.containsKey("term")) {
171 serviceParams.term = optionParams.term
172 }
173 ec.logger.info("MCP GetScreenDetails: Calling direct service ${underlyingService} for field ${field.name} with serviceParams: ${serviceParams}")
174 def optionsResult = ec.service.sync().name(underlyingService).parameters(serviceParams).call()
175 if (optionsResult && optionsResult.resultList) {
176 def optionsList = []
177 optionsResult.resultList.each { opt ->
178 if (opt instanceof Map) {
179 def key = opt.geoId ?: opt.value ?: opt.key
180 def label = opt.label ?: opt.value
181 optionsList << [value: key, label: label]
182 }
183 }
184 if (optionsList) {
185 fieldInfo.options = optionsList
186 ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call, returning from field processing")
187 allFields[field.name] = fieldInfo
188 return // Skip remaining processing for this field
189 } 87 }
88 } catch (Exception e) {
89 ec.logger.error("MCP GetScreenDetails error: ${e.message}", e)
90 result.error = e.message
190 } 91 }
92 return result
191 } 93 }
192 // Fallback for transitions without direct service - try calling via ScreenAsMcpTool
193 // This handles entity-find transitions and others without web-send-json-response
194 if (transitionName && !dynamicOptions.containsKey("serviceName")) {
195 ec.logger.info("MCP GetScreenDetails: Calling transition ${transitionName} via ScreenAsMcpTool for field ${field.name}")
196 def transCallParams = [
197 path: "/" + path,
198 action: transitionName,
199 parameters: optionParams,
200 renderMode: "mcp",
201 sessionId: null
202 ]
203 def transResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool").parameters(transCallParams).call()
204
205 // Parse response - try to extract options from mcp render output
206 if (transResult?.result?.content?.size() > 0) {
207 def responseText = transResult.result.content[0].text
208 if (responseText) {
209 ec.logger.info("MCP GetScreenDetails: Transition returned ${responseText.length()} chars")
210 94
211 // Try to parse as JSON first 95 /**
212 try { 96 * Fetch options for a field with dynamic-options by calling the transition.
213 def jsonObj = new groovy.json.JsonSlurper().parseText(responseText) 97 *
214 98 * This uses CustomScreenTestImpl with skipJsonSerialize(true) to call the transition
215 // Check for options in formMetadata 99 * and capture the raw JSON response - exactly how ScreenRenderImpl.getFieldOptions() works.
216 if (jsonObj.containsKey("formMetadata")) { 100 */
217 def formData = jsonObj.formMetadata 101 private static void fetchOptions(Map fieldInfo, String path, Map parameters, Map dynamicOptions, ExecutionContext ec) {
218 formData.each { formName, formItem -> 102 ec.logger.info("=== fetchOptions START: ${fieldInfo.name} ===")
219 if (formItem.containsKey("fields")) { 103 def transitionName = dynamicOptions.transition
220 def fieldOptions = formItem.fields.find { it.name == field.name } 104 if (!transitionName) {
221 if (fieldOptions && fieldOptions.containsKey("options")) { 105 ec.logger.info("No transition specified for dynamic options")
222 fieldInfo.options = fieldOptions.options
223 ec.logger.info("MCP GetScreenDetails: Retrieved ${fieldInfo.options.size()} options from formMetadata")
224 allFields[field.name] = fieldInfo
225 return 106 return
226 } 107 }
227 }
228 }
229 }
230 108
231 // Fallback to previous logic 109 def optionParams = [:]
232 if (jsonObj instanceof Map && jsonObj.containsKey("resultList")) {
233 def resultList = jsonObj.resultList
234 if (resultList instanceof List) {
235 def optionsList = []
236 resultList.each { opt ->
237 if (opt instanceof Map) {
238 def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
239 def label = opt.label ?: opt.description ?: opt.value
240 optionsList << [value: key, label: label]
241 }
242 }
243 if (optionsList) {
244 fieldInfo.options = optionsList
245 ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options from transition ${transitionName}")
246 allFields[field.name] = fieldInfo
247 return
248 }
249 } else if (jsonObj instanceof Map && jsonObj.containsKey("options")) {
250 fieldInfo.options = jsonObj.options
251 } else if (jsonObj instanceof Map && jsonObj.containsKey("value")) {
252 fieldInfo.options = [[value: jsonObj.value, label: jsonObj.value]]
253 } else {
254 fieldInfo.optionsError = "Unrecognized response format: ${jsonObj.getClass().simpleName}"
255 }
256 } catch (Exception parseEx) {
257 ec.logger.warn("MCP GetScreenDetails: Failed to parse transition response: ${parseEx.message}")
258 fieldInfo.optionsError = "Transition call succeeded but response format not recognized. Try: moqui_browse_screens(path='${path}', action='${transitionName}')"
259 }
260 }
261 }
262 fieldInfo.optionsError = "Call moqui_browse_screens(path='\${path}', action='\${transitionName}') to fetch dropdown options"
263 }
264 if (serviceName) {
265 // Direct service call - same as frontend does
266 ec.logger.info("MCP GetScreenDetails: Calling service ${serviceName} for field ${field.name}")
267 def optionsResult = ec.service.sync().name(serviceName).parameters(optionParams).call()
268 if (optionsResult) {
269 def optionsList = []
270 if (optionsResult instanceof Map && optionsResult.containsKey("resultList")) {
271 def resultList = optionsResult.resultList
272 if (resultList instanceof List) {
273 resultList.each { opt ->
274 if (opt instanceof Map) {
275 def key = opt.geoId ?: opt.value ?: opt.key
276 def label = opt.label ?: opt.value
277 optionsList << [value: key, label: label]
278 }
279 }
280 }
281 } else if (optionsResult instanceof List) {
282 optionsList = optionsResult
283 }
284 110
285 if (optionsList) { 111 // 1. Handle dependsOn (from form XML) - maps field values to service parameters
286 fieldInfo.options = optionsList 112 if (dynamicOptions.dependsOn) {
287 ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options from service ${serviceName}") 113 def depList = dynamicOptions.dependsOn instanceof String ?
288 } 114 new JsonSlurper().parseText(dynamicOptions.dependsOn) : dynamicOptions.dependsOn
289 }
290 } else if (transitionName) {
291 // For transitions, try calling via ScreenAsMcpTool with transition
292 ec.logger.info("MCP GetScreenDetails: Calling transition ${transitionName} for field ${field.name}")
293
294 def transCallParams = [
295 path: "/" + path,
296 action: transitionName,
297 parameters: optionParams,
298 renderMode: "text", // Get raw text response, not JSON
299 sessionId: null
300 ]
301 def transResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool").parameters(transCallParams).call()
302 115
303 // Parse response - transitions return JSON with 'resultList' 116 depList.each { dep ->
304 if (transResult?.result?.content?.size() > 0) { 117 def parts = dep.split('\\|')
305 def responseText = transResult.result.content[0].text 118 def fld = parts[0], prm = parts.size() > 1 ? parts[1] : fld
306 if (responseText) { 119 def val = parameters?.get(fld)
307 ec.logger.info("MCP GetScreenDetails: Transition returned ${responseText.length()} chars")
308 120
309 // Parse JSON from response 121 // Try common form map names if not found at top level
310 try { 122 if (val == null) {
311 def jsonObj = new groovy.json.JsonSlurper().parseText(responseText) 123 ['fieldValues', 'fieldValuesMap', 'formValues', 'formValuesMap', 'formMap'].each { mapName ->
312 124 def mapVal = parameters?.get(mapName as String)
313 if (jsonObj instanceof Map && jsonObj.containsKey("resultList")) { 125 if (mapVal instanceof Map) {
314 def resultList = jsonObj.resultList 126 val = mapVal.get(fld)
315 if (resultList instanceof List) { 127 if (val != null) return
316 def optionsList = []
317 resultList.each { opt ->
318 if (opt instanceof Map) {
319 def key = opt.geoId ?: opt.value ?: opt.key
320 def label = opt.label ?: opt.value
321 optionsList << [value: key, label: label]
322 }
323 }
324 if (optionsList) {
325 fieldInfo.options = optionsList
326 ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options from transition ${transitionName}")
327 } 128 }
328 } 129 }
329 } else if (jsonObj instanceof Map && jsonObj.containsKey("options")) {
330 fieldInfo.options = jsonObj.options
331 } else if (jsonObj instanceof Map && jsonObj.containsKey("value")) {
332 fieldInfo.options = [[value: jsonObj.value, label: jsonObj.value]]
333 } 130 }
334 } catch (Exception parseEx) { 131 if (val != null) optionParams[prm] = val
335 ec.logger.warn("MCP GetScreenDetails: Failed to parse transition response: ${parseEx.message}")
336 fieldInfo.optionsError = "Failed to parse options response"
337 }
338 }
339 }
340 } else {
341 fieldInfo.optionsError = "No service or transition specified in dynamic-options"
342 }
343 } catch (Exception e) {
344 ec.logger.warn("MCP GetScreenDetails: Failed to get options for field ${field.name}: ${e.message}")
345 fieldInfo.optionsError = "Failed to load options: ${e.message}"
346 } 132 }
347 } 133 }
348 134
349 if (!skipField) { 135 // 2. Handle serverSearch fields
350 allFields[field.name] = fieldInfo 136 // If serverSearch is true AND no term is provided, skip fetching (matches framework behavior)
351 } 137 // The framework's getFieldOptions() skips server-search fields entirely for initial load
352 } 138 def isServerSearch = dynamicOptions.serverSearch == true || dynamicOptions.serverSearch == "true"
353 } 139 if (isServerSearch) {
354 } 140 if (parameters?.term != null && parameters.term.toString().length() > 0) {
355 } 141 optionParams.term = parameters.term
142 } else {
143 // Skip fetching options for server-search fields without a term
144 ec.logger.info("Skipping server-search field ${fieldInfo.name} - no term provided")
145 return
356 } 146 }
357 } 147 }
358 148
359 ec.logger.info("MCP GetScreenDetails: Extracted ${allFields.size()} fields") 149 // 3. Use CustomScreenTestImpl with skipJsonSerialize to call the transition
150 // This is exactly how ScreenRenderImpl.getFieldOptions() works in the framework
151 ec.logger.info("Calling transition ${transitionName} via CustomScreenTestImpl with skipJsonSerialize=true, params: ${optionParams}")
360 152
361 // Return specific field or all fields 153 try {
362 if (fieldName) { 154 def ecfi = (ExecutionContextFactoryImpl) ec.factory
363 def specificField = allFields[fieldName] 155
364 if (specificField) { 156 // Build transition path by appending transition name to screen path
365 result.fields[fieldName] = specificField 157 def fullPath = path
366 } else { 158 if (!fullPath.endsWith('/')) fullPath += '/'
367 result.error = "Field not found: ${fieldName}" 159 fullPath += transitionName
368 } 160
369 } else { 161 // Parse path segments for component-based resolution
370 result.fields = allFields.collectEntries { k, v -> [name: k, *:v] } 162 def pathSegments = []
371 } 163 fullPath.split('/').each { if (it && it.trim()) pathSegments.add(it) }
164
165 // Component-based resolution (same as ScreenAsMcpTool)
166 // Path like "PopCommerce/PopCommerceAdmin/Party/FindParty/transition" becomes:
167 // - rootScreen: component://PopCommerce/screen/PopCommerceAdmin.xml
168 // - testScreenPath: Party/FindParty/transition
169 def rootScreen = "component://webroot/screen/webroot.xml"
170 def testScreenPath = fullPath
171
172 if (pathSegments.size() >= 2) {
173 def componentName = pathSegments[0]
174 def rootScreenName = pathSegments[1]
175 def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml"
176
177 if (ec.resource.getLocationReference(compRootLoc).exists) {
178 ec.logger.info("fetchOptions: Using component root: ${compRootLoc}")
179 rootScreen = compRootLoc
180 testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : ""
181 }
182 }
183
184 // Use CustomScreenTestImpl with skipJsonSerialize - like ScreenRenderImpl.getFieldOptions()
185 def screenTest = new CustomScreenTestImpl(ecfi)
186 .rootScreen(rootScreen)
187 .skipJsonSerialize(true)
188 .auth(ec.user.username)
189
190 ec.logger.info("Rendering transition path: ${testScreenPath} (from root: ${rootScreen})")
191 def str = screenTest.render(testScreenPath, optionParams, "GET")
192
193 // Get JSON object directly (like web UI does)
194 def jsonObj = str.getJsonObject()
195 ec.logger.info("Transition returned jsonObj: ${jsonObj?.getClass()?.simpleName}, size: ${jsonObj instanceof Collection ? jsonObj.size() : 'N/A'}")
196
197 // Extract value-field and label-field from dynamic-options config
198 def valueField = dynamicOptions.valueField ?: dynamicOptions.'value-field' ?: 'value'
199 def labelField = dynamicOptions.labelField ?: dynamicOptions.'label-field' ?: 'label'
200
201 // Process the JSON response - same logic as ScreenRenderImpl.getFieldOptions()
202 List optsList = null
203 if (jsonObj instanceof List) {
204 optsList = (List) jsonObj
205 } else if (jsonObj instanceof Map) {
206 Map jsonMap = (Map) jsonObj
207 // Try 'options' key first (standard pattern)
208 def optionsObj = jsonMap.get("options")
209 if (optionsObj instanceof List) {
210 optsList = (List) optionsObj
211 } else if (jsonMap.get("resultList") instanceof List) {
212 // Some services return resultList
213 optsList = (List) jsonMap.get("resultList")
214 }
215 }
216
217 if (optsList != null && optsList.size() > 0) {
218 fieldInfo.options = optsList.collect { entryObj ->
219 if (entryObj instanceof Map) {
220 Map entryMap = (Map) entryObj
221 // Try configured fields first, then common fallbacks
222 def value = entryMap.get(valueField) ?:
223 entryMap.get('value') ?:
224 entryMap.get('geoId') ?:
225 entryMap.get('enumId') ?:
226 entryMap.get('id') ?:
227 entryMap.get('key')
228 def label = entryMap.get(labelField) ?:
229 entryMap.get('label') ?:
230 entryMap.get('description') ?:
231 entryMap.get('name') ?:
232 entryMap.get('text') ?:
233 value?.toString()
234 [value: value, label: label]
372 } else { 235 } else {
373 ec.logger.warn("MCP GetScreenDetails: No formMetadata found in semantic state") 236 [value: entryObj, label: entryObj?.toString()]
374 result.error = "No form data available"
375 } 237 }
238 }.findAll { it.value != null }
239
240 ec.logger.info("Successfully extracted ${fieldInfo.options.size()} autocomplete options via ScreenTest")
376 } else { 241 } else {
377 result.error = "Invalid response from ScreenAsMcpTool" 242 ec.logger.info("No options found in transition response")
243
244 // Check if there was output but no JSON (might be an error)
245 def output = str.getOutput()
246 if (output && output.length() > 0 && output.length() < 500) {
247 ec.logger.warn("Transition output (no JSON): ${output}")
378 } 248 }
379 } else {
380 result.error = "No content returned from ScreenAsMcpTool"
381 } 249 }
250
382 } catch (Exception e) { 251 } catch (Exception e) {
383 ec.logger.error("MCP GetScreenDetails: Error: ${e.getClass().simpleName}: ${e.message}") 252 ec.logger.warn("Error calling transition ${transitionName}: ${e.message}", e)
384 result.error = "Screen resolution failed: ${e.message}" 253 fieldInfo.optionsError = "Transition call failed: ${e.message}"
385 } 254 }
386
387 return result
388 } 255 }
389 } 256 }
......
1 package org.moqui.mcp
2
3 import org.moqui.context.ExecutionContext
4
5 class McpFieldOptionsService {
6 static service(String path, String fieldName, Map parameters, ExecutionContext ec) {
7 if (!path) {
8 throw new IllegalArgumentException("path is required")
9 }
10
11 ec.logger.info("MCP GetScreenDetails: Getting details for screen ${path}, field ${fieldName ?: 'all'}")
12
13 def result = [
14 screenPath: path,
15 fields: [:]
16 ]
17
18 try {
19 // First, render screen to get form metadata (including dynamicOptions)
20 def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
21 .parameters([path: path, parameters: parameters ?: [:], renderMode: "mcp", sessionId: null, terse: false])
22 .call()
23
24 if (browseResult?.result?.content?.size() > 0) {
25 def rawText = browseResult.result.content[0].text
26 if (rawText && rawText.startsWith("{")) {
27 def resultObj = new groovy.json.JsonSlurper().parseText(rawText)
28 def semanticData = resultObj?.semanticState?.data
29
30 if (semanticData?.containsKey("formMetadata")) {
31 def formMetadata = semanticData.formMetadata
32 def allFields = [:]
33
34 if (formMetadata instanceof Map) {
35 formMetadata.each { formName, formItem ->
36 if (formItem instanceof Map && formItem.containsKey("fields")) {
37 def fieldList = formItem.fields
38 if (fieldList instanceof Collection) {
39 fieldList.each { field ->
40 if (field instanceof Map && field.containsKey("name")) {
41 def fieldInfo = [
42 name: field.name,
43 title: field.title,
44 type: field.type,
45 required: field.required ?: false
46 ]
47
48 // Add dropdown options if available (static options)
49 if (field.type == "dropdown" && field.containsKey("options")) {
50 fieldInfo.options = field.options
51 }
52
53 // Add dynamic options metadata and actually fetch options
54 if (field.containsKey("dynamicOptions")) {
55 def dynamicOptions = field.dynamicOptions
56 fieldInfo.dynamicOptions = dynamicOptions
57
58 try {
59 def serviceName = dynamicOptions.containsKey("serviceName") ? dynamicOptions.serviceName : null
60 def transitionName = dynamicOptions.containsKey("transition") ? dynamicOptions.transition : null
61 def optionParams = [:]
62
63 // Parse inParameterMap if specified (extracted from transition XML)
64 def inParameterMap = [:]
65 if (dynamicOptions.containsKey("inParameterMap") && dynamicOptions.inParameterMap && dynamicOptions.inParameterMap.trim()) {
66 // Parse in-map format: "[target1:source1,target2:source2]"
67 def mapContent = dynamicOptions.inParameterMap.trim()
68 if (mapContent.startsWith("[") && mapContent.endsWith("]")) {
69 def innerContent = mapContent.substring(1, mapContent.length() - 1)
70 innerContent.split(',').each { mapping ->
71 def colonIndex = mapping.indexOf(':')
72 if (colonIndex > 0) {
73 def targetParam = mapping.substring(0, colonIndex).trim()
74 def sourceFields = mapping.substring(colonIndex + 1).trim()
75 // Handle multiple source fields separated by comma
76 sourceFields.split(',').each { sourceField ->
77 def sourceValue = parameters?.get(sourceField.trim())
78 if (sourceValue != null) {
79 inParameterMap[targetParam] = sourceValue
80 ec.logger.info("MCP GetScreenDetails: Mapped in-param ${sourceField} -> ${targetParam} = ${sourceValue}")
81 }
82 }
83 }
84 }
85 }
86 }
87
88 // Handle depends-on fields and parameter overrides
89 ec.logger.info("MCP GetScreenDetails: Processing depends-on for field ${field.name}")
90
91 // Parse depends-on list (may include parameter overrides like "field|parameter")
92 def dependsOnFields = []
93 if (dynamicOptions.containsKey("dependsOn") && dynamicOptions.dependsOn) {
94 if (dynamicOptions.dependsOn instanceof String) {
95 dependsOnFields = new groovy.json.JsonSlurper().parseText(dynamicOptions.dependsOn)
96 } else if (dynamicOptions.dependsOn instanceof List) {
97 dependsOnFields = dynamicOptions.dependsOn
98 }
99 }
100
101 // Process each depends-on field with potential parameter override
102 dependsOnFields.each { depFieldOrTuple ->
103 def depField = depFieldOrTuple
104 def depParameter = depFieldOrTuple // Default: use field name as parameter name
105
106 // Check if depends-on item is a "field|parameter" tuple
107 if (depFieldOrTuple instanceof String && depFieldOrTuple.contains("|")) {
108 def parts = depFieldOrTuple.split("\\|")
109 if (parts.size() == 2) {
110 depField = parts[0].trim()
111 depParameter = parts[1].trim()
112 }
113 }
114
115 def depValue = parameters?.get(depField)
116 if (depValue == null) {
117 ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} has no value in parameters")
118 } else {
119 ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} = ${depValue}, targetParam = ${depParameter}")
120 // Add to optionParams - use depParameter as key if specified, otherwise use depField
121 optionParams[depParameter ?: depField] = depValue
122 }
123 }
124
125 // For server-search fields, add term parameter
126 if (dynamicOptions.containsKey("serverSearch") && dynamicOptions.serverSearch) {
127 if (parameters?.containsKey("term")) {
128 def searchTerm = parameters.term
129 if (searchTerm && searchTerm.length() >= (dynamicOptions.minLength ?: 0)) {
130 optionParams.term = searchTerm
131 ec.logger.info("MCP GetScreenDetails: Server search term = '${searchTerm}'")
132 } else {
133 ec.logger.info("MCP GetScreenDetails: No term provided, will return full list")
134 }
135 }
136 }
137
138 // For transitions with web-send-json-response, try to extract and call service directly
139 // These transitions wrap BasicServices.get#GeoRegionsForDropDown
140 if (dynamicOptions.containsKey("serviceName") && dynamicOptions.serviceName) {
141 // Direct service call - use extracted service name from transition XML
142 ec.logger.info("MCP GetScreenDetails: Calling direct service ${dynamicOptions.serviceName} for field ${field.name} with optionParams: ${optionParams}")
143 def optionsResult = ec.service.sync().name(dynamicOptions.serviceName).parameters(optionParams).call()
144 if (optionsResult && optionsResult.resultList) {
145 def optionsList = []
146 optionsResult.resultList.each { opt ->
147 if (opt instanceof Map) {
148 def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
149 def label = opt.label ?: opt.description ?: opt.value
150 optionsList << [value: key, label: label]
151 }
152 }
153 if (optionsList) {
154 fieldInfo.options = optionsList
155 ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call")
156 allFields[field.name] = fieldInfo
157 return // Skip remaining processing for this field
158 }
159 }
160 } else {
161 // Fallback for hardcoded transitions or when serviceName not available
162 ec.logger.info("MCP GetScreenDetails: No serviceName found, checking hardcoded transitions")
163 if (transitionName == "getGeoCountryStates" || transitionName == "getGeoStateCounties") {
164 def underlyingService = "org.moqui.impl.BasicServices.get#GeoRegionsForDropDown"
165 // Map depends-on field names to service parameter names (e.g., countryGeoId -> geoId)
166 def serviceParams = [:]
167 if (optionParams.containsKey("countryGeoId")) {
168 serviceParams.geoId = optionParams.countryGeoId
169 }
170 if (optionParams.containsKey("stateGeoId")) {
171 serviceParams.geoId = optionParams.stateGeoId
172 serviceParams.geoTypeEnumId = "GEOT_COUNTY"
173 }
174 if (optionParams.containsKey("term")) {
175 serviceParams.term = optionParams.term
176 }
177 ec.logger.info("MCP GetScreenDetails: Calling direct service ${underlyingService} for field ${field.name} with serviceParams: ${serviceParams}")
178 def optionsResult = ec.service.sync().name(underlyingService).parameters(serviceParams).call()
179 if (optionsResult && optionsResult.resultList) {
180 def optionsList = []
181 optionsResult.resultList.each { opt ->
182 if (opt instanceof Map) {
183 def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
184 def label = opt.label ?: opt.description ?: opt.value
185 optionsList << [value: key, label: label]
186 }
187 }
188 if (optionsList) {
189 fieldInfo.options = optionsList
190 ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call")
191 allFields[field.name] = fieldInfo
192 return // Skip remaining processing for this field
193 }
194 }
195 }
196 }
197
198 } catch (Exception e) {
199 ec.logger.warn("MCP GetScreenDetails: Failed to get options for field ${field.name}: ${e.message}")
200 fieldInfo.optionsError = "Failed to load options: ${e.message}"
201 }
202 }
203
204 allFields[field.name] = fieldInfo
205 }
206 }
207 }
208 }
209 }
210 }
211
212 ec.logger.info("MCP GetScreenDetails: Extracted ${allFields.size()} fields")
213
214 // Return specific field or all fields
215 if (fieldName) {
216 def specificField = allFields[fieldName]
217 if (specificField) {
218 result.fields[fieldName] = specificField
219 } else {
220 result.error = "Field not found: ${fieldName}"
221 }
222 } else {
223 result.fields = allFields.collectEntries { k, v -> [name: k, *:v] }
224 }
225 } else {
226 ec.logger.warn("MCP GetScreenDetails: No formMetadata found in semantic state")
227 result.error = "No form data available"
228 }
229 } else {
230 result.error = "Invalid response from ScreenAsMcpTool"
231 }
232 } catch (Exception e) {
233 ec.logger.error("MCP GetScreenDetails: Error: ${e.getClass().simpleName}: ${e.message}")
234 result.error = "Screen resolution failed: ${e.message}"
235 }
236 }
237
238 return result
239 }
240 }
...@@ -46,18 +46,18 @@ class UiNarrativeBuilder { ...@@ -46,18 +46,18 @@ class UiNarrativeBuilder {
46 return count 46 return count
47 } 47 }
48 48
49 Map<String, Object> buildNarrative(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) { 49 Map<String, Object> buildNarrative(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath) {
50 def narrative = [:] 50 def narrative = [:]
51 51
52 narrative.screen = describeScreen(screenDef, semanticState, isTerse) 52 narrative.screen = describeScreen(screenDef, semanticState)
53 narrative.actions = describeActions(screenDef, semanticState, currentPath, isTerse) 53 narrative.actions = describeActions(screenDef, semanticState, currentPath)
54 narrative.navigation = describeLinks(semanticState, currentPath, isTerse) 54 narrative.navigation = describeLinks(semanticState, currentPath)
55 narrative.notes = describeNotes(semanticState, currentPath, isTerse) 55 narrative.notes = describeNotes(semanticState, currentPath)
56 56
57 return narrative 57 return narrative
58 } 58 }
59 59
60 String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState, boolean isTerse) { 60 String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState) {
61 def screenName = screenDef?.getScreenName() ?: "Screen" 61 def screenName = screenDef?.getScreenName() ?: "Screen"
62 def sb = new StringBuilder() 62 def sb = new StringBuilder()
63 63
...@@ -103,7 +103,7 @@ class UiNarrativeBuilder { ...@@ -103,7 +103,7 @@ class UiNarrativeBuilder {
103 return sb.toString() 103 return sb.toString()
104 } 104 }
105 105
106 List<String> describeActions(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) { 106 List<String> describeActions(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath) {
107 def actions = [] 107 def actions = []
108 108
109 def transitions = semanticState?.actions 109 def transitions = semanticState?.actions
...@@ -161,7 +161,7 @@ class UiNarrativeBuilder { ...@@ -161,7 +161,7 @@ class UiNarrativeBuilder {
161 return 'screen-transition' 161 return 'screen-transition'
162 } 162 }
163 163
164 List<String> describeLinks(Map<String, Object> semanticState, String currentPath, boolean isTerse) { 164 List<String> describeLinks(Map<String, Object> semanticState, String currentPath) {
165 def navigation = [] 165 def navigation = []
166 166
167 def links = semanticState?.data?.links 167 def links = semanticState?.data?.links
...@@ -201,7 +201,7 @@ class UiNarrativeBuilder { ...@@ -201,7 +201,7 @@ class UiNarrativeBuilder {
201 return navigation 201 return navigation
202 } 202 }
203 203
204 List<String> describeNotes(Map<String, Object> semanticState, String currentPath, boolean isTerse) { 204 List<String> describeNotes(Map<String, Object> semanticState, String currentPath) {
205 def notes = [] 205 def notes = []
206 206
207 def data = semanticState?.data 207 def data = semanticState?.data
...@@ -210,7 +210,7 @@ class UiNarrativeBuilder { ...@@ -210,7 +210,7 @@ class UiNarrativeBuilder {
210 if (value instanceof Map && value.containsKey('_truncated') && value._truncated == true) { 210 if (value instanceof Map && value.containsKey('_truncated') && value._truncated == true) {
211 def total = value._totalCount ?: 0 211 def total = value._totalCount ?: 0
212 def shown = value._items?.size() ?: 0 212 def shown = value._items?.size() ?: 0
213 notes << "List truncated: showing ${shown} of ${total} item${total > 1 ? 's' : ''}. Set terse=false to view all." 213 notes << "List truncated: showing ${shown} of ${total} item${total > 1 ? 's' : ''}. Use pagination for more."
214 } 214 }
215 } 215 }
216 } 216 }
......
1 /*
2 * This software is in the public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 */
5 package org.moqui.mcp.test
6
7 import org.moqui.Moqui
8 import org.moqui.context.ExecutionContext
9 import spock.lang.Shared
10 import spock.lang.Specification
11 import spock.lang.Stepwise
12
13 @Stepwise
14 class AutocompleteTest extends Specification {
15 @Shared ExecutionContext ec
16 @Shared SimpleMcpClient client
17
18 def setupSpec() {
19 ec = Moqui.getExecutionContext()
20 client = new SimpleMcpClient()
21 client.initializeSession()
22
23 // Log in to ensure permissions
24 ec.user.internalLoginUser("john.doe")
25 }
26
27 def cleanupSpec() {
28 if (client) client.closeSession()
29 if (ec) ec.destroy()
30 }
31
32 def "Test getCategoryList Autocomplete"() {
33 when:
34 println "🔍 Testing getCategoryList autocomplete on Search screen"
35
36 // 1. Get screen details to find the field and transition
37 def details = client.callTool("moqui_get_screen_details", [
38 path: "component://SimpleScreens/screen/SimpleScreens/Catalog/Search.xml",
39 fieldName: "productCategoryId"
40 ])
41
42 println "📋 Screen details result: ${details}"
43
44 then:
45 details != null
46 !details.error
47 !details.result?.error
48
49 def content = details.result?.content
50 content != null
51 content.size() > 0
52
53 // Parse the text content from the response
54 def jsonText = content[0].text
55 def jsonResult = new groovy.json.JsonSlurper().parseText(jsonText)
56
57 def field = jsonResult.fields?.productCategoryId
58 field != null
59 field.dynamicOptions != null
60 field.dynamicOptions.transition == "getCategoryList"
61 field.dynamicOptions.serverSearch == true
62
63 println "✅ Field metadata verified: ${field.dynamicOptions}"
64
65 when:
66 println "🔍 Testing explicit transition call via TransitionAsMcpTool"
67
68 // 2. Call the transition directly to simulate typing
69 def transitionResult = ec.service.sync().name("McpServices.execute#TransitionAsMcpTool")
70 .parameters([
71 path: "component://SimpleScreens/screen/SimpleScreens/Catalog/Search.xml",
72 transitionName: "getCategoryList",
73 parameters: [term: ""]
74 ])
75 .call()
76
77 println "🔄 Transition result: ${transitionResult}"
78
79 then:
80 transitionResult != null
81 transitionResult.result != null
82 !transitionResult.result.error
83 transitionResult.result.data != null
84 transitionResult.result.data instanceof List
85 transitionResult.result.data.size() > 0
86
87 println "✅ Found ${transitionResult.result.data.size()} categories via transition"
88 println "📝 First category: ${transitionResult.result.data[0]}"
89 }
90 }
...@@ -155,6 +155,23 @@ class SimpleMcpClient { ...@@ -155,6 +155,23 @@ class SimpleMcpClient {
155 } 155 }
156 156
157 /** 157 /**
158 * Call any tool
159 */
160 Map callTool(String toolName, Map arguments = [:]) {
161 try {
162 def result = makeJsonRpcRequest("tools/call", [
163 name: toolName,
164 arguments: arguments
165 ])
166
167 return result ?: [error: [message: "No response from server"]]
168 } catch (Exception e) {
169 println "Error calling tool ${toolName}: ${e.message}"
170 return [error: [message: e.message]]
171 }
172 }
173
174 /**
158 * Call a screen tool 175 * Call a screen tool
159 */ 176 */
160 Map callScreen(String screenPath, Map parameters = [:]) { 177 Map callScreen(String screenPath, Map parameters = [:]) {
......