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
Showing
9 changed files
with
474 additions
and
681 deletions
| 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 = [:]) { | ... | ... |
-
Please register or sign in to post a comment