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
arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
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
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
......
......@@ -143,34 +143,45 @@
<#if fieldSubNode["text-line"]?has_content><#assign fieldMeta = fieldMeta + {"type": "text"}></#if>
<#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if>
<#if fieldSubNode["drop-down"]?has_content>
<#assign dropdownOptions = sri.getFieldOptions(.node)!>
<#if dropdownOptions?has_content>
<#assign fieldMeta = fieldMeta + {"type": "dropdown", "options": dropdownOptions?js_string!}>
<#else>
<#assign dropdownNode = fieldSubNode["drop-down"]!>
<#if dropdownNode?is_hash>
<#assign dynamicOptionNode = dropdownNode["dynamic-options"][0]!>
<#else>
<#assign dynamicOptionNode = dropdownNode[0]["dynamic-options"][0]!>
<#-- Get the actual drop-down node (getFieldOptions expects the widget node, not its parent) -->
<#assign dropdownNodeList = fieldSubNode["drop-down"]>
<#assign dropdownNode = (dropdownNodeList?is_sequence)?then(dropdownNodeList[0], dropdownNodeList)>
<#-- Evaluate any 'set' nodes from widget-template-include before getting options -->
<#-- These set variables like enumTypeId needed by entity-options -->
<#assign setNodes = fieldSubNode["set"]!>
<#list setNodes as setNode>
<#if setNode["@field"]?has_content>
<#assign dummy = sri.setInContext(setNode)>
</#if>
<#if dynamicOptionNode?has_content>
</#list>
<#-- Get dropdown options - pass the drop-down node, not fieldSubNode -->
<#assign dropdownOptions = sri.getFieldOptions(dropdownNode)!>
<#if (dropdownOptions?size!0) gt 0>
<#-- Build options list from the LinkedHashMap -->
<#assign optionsList = []>
<#list (dropdownOptions.keySet())! as optKey>
<#assign optLabel = (dropdownOptions.get(optKey))!optKey>
<#assign optionsList = optionsList + [{"value": optKey, "label": optLabel}]>
</#list>
<#assign fieldMeta = fieldMeta + {"type": "dropdown", "options": optionsList}>
<#else>
<#-- No static options - check for dynamic-options -->
<#assign dynamicOptionsList = dropdownNode["dynamic-options"]!>
<#if dynamicOptionsList?has_content && dynamicOptionsList?size gt 0>
<#assign dynamicOptionNode = dynamicOptionsList[0]>
<#-- Try to extract transition metadata for better autocomplete support -->
<#assign transitionMetadata = {}>
<#if dynamicOptionNode["@transition"]?has_content>
<#assign transitionNode = sri.getScreenDefinition().getTransitionItem(dynamicOptionNode["@transition"]!"")!>
<#if transitionNode?has_content>
<#-- Extract service name if present -->
<#assign serviceCallNode = transitionNode["service-call"][0]!>
<#if serviceCallNode?has_content && serviceCallNode["@name"]?has_content>
<#assign transitionMetadata = transitionMetadata + {"serviceName": (serviceCallNode["@name"]!"")}>
</#if>
<#-- Extract in-map parameter mapping -->
<#if serviceCallNode["@in-map"]?has_content>
<#assign transitionMetadata = transitionMetadata + {"inParameterMap": ((serviceCallNode["@in-map"]!"")?js_string)!""}>
<#elseif transitionNode["parameter"]?has_content>
<#assign paramNode = transitionNode["parameter"][0]!>
<#if paramNode?has_content>
<#assign transitionMetadata = transitionMetadata + {"inParameterMap": "[]"}>
<#assign activeScreenDef = sri.getActiveScreenDef()!>
<#if activeScreenDef?has_content>
<#assign transitionItem = activeScreenDef.getTransitionItem(dynamicOptionNode["@transition"]!"", null)!>
<#if transitionItem?has_content>
<#assign serviceName = transitionItem.getSingleServiceName()!"">
<#if serviceName?has_content && serviceName != "">
<#assign transitionMetadata = transitionMetadata + {"serviceName": serviceName}>
</#if>
</#if>
</#if>
......@@ -237,9 +248,9 @@
<#assign formNode = formListInfo.getFormNode()>
<#assign formListColumnList = formListInfo.getAllColInfo()>
<#assign listObject = formListInfo.getListObject(false)!>
<#assign totalItems = listObject?size>
<#assign totalItems = (listObject?size)!0>
<#if mcpSemanticData?? && listObject?has_content>
<#if mcpSemanticData??>
<#assign formName = (.node["@name"]!"")?string>
<#assign displayedItems = (totalItems > 50)?then(50, totalItems)>
<#assign isTruncated = (totalItems > 50)>
......@@ -248,9 +259,99 @@
<#assign fieldNode = columnFieldList[0]>
<#assign columnNames = columnNames + [fieldNode["@name"]!""]>
</#list>
<#-- Extract Field Metadata for form-list (header fields usually) -->
<#assign fieldMetaList = []>
<#list formListColumnList as columnFieldList>
<#assign fieldNode = columnFieldList[0]>
<#assign fieldSubNode = fieldNode["header-field"][0]!fieldNode["default-field"][0]!fieldNode["conditional-field"][0]!>
<#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content>
<#assign title><@fieldTitle fieldSubNode/></#assign>
<#assign fieldMeta = {"name": (fieldNode["@name"]!""), "title": (title!), "required": (fieldNode["@required"]! == "true")}>
<#if fieldSubNode["text-line"]?has_content><#assign fieldMeta = fieldMeta + {"type": "text"}></#if>
<#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if>
<#if fieldSubNode["drop-down"]?has_content>
<#-- Evaluate any 'set' nodes from widget-template-include before getting options -->
<#list fieldSubNode["set"]! as setNode>
<#if setNode["@field"]?has_content>
<#assign dummy = sri.setInContext(setNode)>
</#if>
</#list>
<#assign dropdownOptions = sri.getFieldOptions(fieldSubNode)!>
<#if dropdownOptions?has_content && dropdownOptions?size gt 0>
<#-- Convert LinkedHashMap<String,String> to list of {value, label} objects for JSON -->
<#assign optionsList = []>
<#list dropdownOptions?keys as optKey>
<#assign optionsList = optionsList + [{"value": optKey, "label": dropdownOptions[optKey]!optKey}]>
</#list>
<#assign fieldMeta = fieldMeta + {"type": "dropdown", "options": optionsList}>
<#else>
<#assign dropdownNode = fieldSubNode["drop-down"]!>
<#-- Robust dynamic-options extraction -->
<#assign actualDropdown = (dropdownNode?is_sequence)?then(dropdownNode[0]!dropdownNode, dropdownNode)>
<#assign dynamicOptionsList = actualDropdown["dynamic-options"]!>
<#if dynamicOptionsList?has_content && dynamicOptionsList?size gt 0>
<#assign dynamicOptionNode = dynamicOptionsList[0]>
<#-- Try to extract transition metadata for better autocomplete support -->
<#assign transitionMetadata = {}>
<#if dynamicOptionNode["@transition"]?has_content>
<#assign activeScreenDef = sri.getActiveScreenDef()!>
<#if activeScreenDef?has_content>
<#assign transitionItem = activeScreenDef.getTransitionItem(dynamicOptionNode["@transition"]!"", null)!>
<#if transitionItem?has_content>
<#assign serviceName = transitionItem.getSingleServiceName()!"">
<#if serviceName?has_content && serviceName != "">
<#assign transitionMetadata = transitionMetadata + {"serviceName": serviceName}>
</#if>
</#if>
</#if>
</#if>
<#assign dependsOnList = []>
<#list dynamicOptionNode["depends-on"]! as depNode>
<#assign depField = depNode["@field"]!"">
<#assign depParameter = depNode["@parameter"]!depField>
<#assign dependsOnItem = depField + "|" + depParameter>
<#assign dependsOnList = dependsOnList + [dependsOnItem]>
</#list>
<#assign dependsOnJson = '[]'>
<#if dependsOnList?size gt 0>
<#assign dependsOnJson = '['>
<#list dependsOnList as dep><#if dep_index gt 0><#assign dependsOnJson = dependsOnJson + ', '></#if><#assign dependsOnJson = dependsOnJson + '"' + dep + '"'></#list>
<#assign dependsOnJson = dependsOnJson + ']'>
</#if>
<#assign fieldMeta = fieldMeta + {"type": "dropdown", "dynamicOptions": {
"transition": (dynamicOptionNode["@transition"]!""),
"serverSearch": (dynamicOptionNode["@server-search"]! == "true"),
"minLength": (dynamicOptionNode["@min-length"]!"0"),
"parameterMap": ((dynamicOptionNode["@parameter-map"]!"")?js_string)!"",
"dependsOn": dependsOnJson
} + transitionMetadata}>
<#else>
<#assign fieldMeta = fieldMeta + {"type": "dropdown"}>
</#if>
</#if>
</#if>
<#if fieldSubNode["check"]?has_content><#assign fieldMeta = fieldMeta + {"type": "checkbox"}></#if>
<#if fieldSubNode["radio"]?has_content><#assign fieldMeta = fieldMeta + {"type": "radio"}></#if>
<#if fieldSubNode["date-find"]?has_content><#assign fieldMeta = fieldMeta + {"type": "date"}></#if>
<#if fieldSubNode["display"]?has_content || fieldSubNode["display-entity"]?has_content><#assign fieldMeta = fieldMeta + {"type": "display"}></#if>
<#if fieldSubNode["link"]?has_content><#assign fieldMeta = fieldMeta + {"type": "link"}></#if>
<#if fieldSubNode["file"]?has_content><#assign fieldMeta = fieldMeta + {"type": "file-upload"}></#if>
<#if fieldSubNode["hidden"]?has_content><#assign fieldMeta = fieldMeta + {"type": "hidden"}></#if>
<#assign fieldMetaList = fieldMetaList + [fieldMeta]>
</#if>
</#list>
<#assign dummy = ec.context.put("tempListObject", listObject)!>
<#assign dummy = ec.context.put("tempColumnNames", columnNames)!>
<#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])", "")!>
<#assign dummy = ec.context.put("tempFieldMetaList", fieldMetaList)!>
<#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])", "")!>
</#if>
<#-- Header Row -->
......
......@@ -17,7 +17,6 @@ warranty.
<parameter name="parameters" type="Map"/>
<parameter name="renderMode" default="mcp"/>
<parameter name="sessionId"/>
<parameter name="terse" type="Boolean" default="false"/>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
......@@ -48,8 +47,7 @@ warranty.
path: screenPath,
parameters: parameters ?: [:],
renderMode: renderMode ?: "mcp",
sessionId: sessionId,
terse: terse == true
sessionId: sessionId
]
if (subscreenName) screenCallParams.subscreenName = subscreenName
......@@ -86,8 +84,7 @@ warranty.
uiNarrative = narrativeBuilder.buildNarrative(
screenDefForNarrative,
semanticState,
path,
terse == true
path
)
ec.logger.info("RenderScreenNarrative: Generated UI narrative for ${path}")
} catch (Exception e) {
......
......@@ -569,7 +569,6 @@
<parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter>
<parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter>
<parameter name="sessionId"><description>Session ID for user context restoration</description></parameter>
<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>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
......@@ -639,11 +638,11 @@ def getWikiInstructions = { lookupPath ->
return null
}
// Optimized recursive serializer for Moqui/Java objects to JSON-friendly Map/List
// When terse=true, returns minimal data with truncation metadata for easy access to full version
// Recursive serializer for Moqui/Java objects to JSON-friendly Map/List
// Applies reasonable safety limits to prevent massive payloads
def serializeMoquiObject
serializeMoquiObject = { obj, depth = 0, isTerse = false ->
if (depth > 5) return "..." // Prevent deep recursion
serializeMoquiObject = { obj, depth = 0 ->
if (depth > 8) return "..." // Prevent deep recursion
if (obj == null) return null
if (obj instanceof Map) {
......@@ -654,44 +653,31 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
if (keyStr.startsWith("_") || keyStr == "ec" || keyStr == "sri") return
// Skip audit fields to reduce payload
if (keyStr in ["lastUpdatedStamp", "lastUpdatedTxStamp", "createdDate", "createdTxStamp", "createdByUserLogin"]) return
def value = serializeMoquiObject(v, depth + 1, isTerse)
def value = serializeMoquiObject(v, depth + 1)
if (value != null) newMap[keyStr] = value
}
return newMap
}
if (obj instanceof Iterable) {
def list = obj.collect()
// Apply truncation only if terse mode is enabled
if (isTerse && list.size() > 50) {
ec.logger.info("serializeMoquiObject: Terse mode - truncating list from ${list.size()} to 50 items")
def truncated = list.take(50)
def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
// Safety limit: truncate very large lists (10000+ items)
def maxItems = 10000
if (list.size() > maxItems) {
ec.logger.info("serializeMoquiObject: Truncating large list from ${list.size()} to ${maxItems} items")
def truncated = list.take(maxItems)
def resultList = truncated.collect { serializeMoquiObject(it, depth + 1) }
return [
_items: resultList,
_totalCount: list.size(),
_truncated: true,
_hasMore: true,
_message: "Terse mode: showing first 50 of ${list.size()} items. Set terse=false to get full data."
_message: "Showing first ${maxItems} of ${list.size()} items. Use pagination for more."
]
}
// Increased limits for non-terse mode - effectively unlimited for operational use
def maxItems = isTerse ? 50 : 1000
def truncated = list.take(maxItems)
def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
if (!isTerse && list.size() > maxItems) {
ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to ${maxItems} items for safety")
return [
_items: resultList,
_totalCount: list.size(),
_truncated: true,
_hasMore: true,
_message: "Truncated to ${maxItems} items (size safety limit reached)"
]
}
return resultList
return list.collect { serializeMoquiObject(it, depth + 1) }
}
if (obj instanceof org.moqui.entity.EntityValue) {
return serializeMoquiObject(obj.getMap(), depth + 1, isTerse)
return serializeMoquiObject(obj.getMap(), depth + 1)
}
if (obj instanceof java.sql.Timestamp || obj instanceof java.util.Date) {
return obj.toString()
......@@ -700,22 +686,13 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
return obj
}
if (obj instanceof String) {
// Apply truncation only if terse mode is enabled
if (isTerse && obj.length() > 5000) {
return [
_value: obj.substring(0, 5000) + "...",
_fullLength: obj.length(),
_truncated: true,
_message: "Terse mode: truncated to 5000 chars. Set terse=false to get more data."
]
}
// Non-terse limit increased to 50000 for safety
if (!isTerse && obj.length() > 50000) {
// Safety limit: truncate very large strings (1MB+)
if (obj.length() > 1000000) {
return [
_value: obj.substring(0, 50000) + "...",
_value: obj.substring(0, 1000000) + "...",
_fullLength: obj.length(),
_truncated: true,
_message: "Truncated to 50000 chars for safety."
_message: "Truncated to 1MB for safety."
]
}
return obj
......@@ -727,11 +704,11 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
if (obj instanceof org.moqui.entity.EntityFind) {
return null
}
// Fallback for unknown types - truncate if too long
// Fallback for unknown types
def str = obj.toString()
if (str.length() > 200) {
if (str.length() > 10000) {
return [
_value: str.substring(0, 200) + "...",
_value: str.substring(0, 10000) + "...",
_fullLength: str.length(),
_truncated: true
]
......@@ -892,12 +869,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
// Check for errors in execution context after service call
def hasError = ec.message.hasError()
// Flush transaction to ensure data is committed
ec.getTransaction().flush()
// Check again after flush - this catches constraint violations that occur during flush
hasError = hasError || ec.message.hasError()
if (hasError) {
actionResult = [
action: action,
......@@ -949,24 +920,22 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
def testRender = screenTest.render(relativePath, renderParams, "POST")
output = testRender.getOutput()
// --- NEW: Semantic State Extraction ---
// --- Semantic State Extraction ---
def postContext = testRender.getPostRenderContext()
def semanticState = [:]
def isTerse = context.terse == true
// Get final screen definition using resolved screen location
def finalScreenDef = resolvedScreenDef
if (finalScreenDef && postContext) {
semanticState.screenPath = inputScreenPath
semanticState.terse = isTerse
semanticState.data = [:]
// Use the explicit semantic data captured by macros if available
def explicitData = postContext.get("mcpSemanticData")
if (explicitData instanceof Map) {
explicitData.each { k, v ->
semanticState.data[k] = serializeMoquiObject(v, 0, isTerse)
semanticState.data[k] = serializeMoquiObject(v, 0)
}
}
......@@ -1008,7 +977,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
// Add value if exists
if (value != null) {
paramInfo.value = serializeMoquiObject(value, 0, isTerse)
paramInfo.value = serializeMoquiObject(value, 0)
}
// Extract parameter type - try multiple approaches
......@@ -1064,7 +1033,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
// Log semantic state size for optimization tracking
def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString()
def semanticStateSize = semanticStateJson.length()
ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}")
ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}")
}
ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
......@@ -1088,13 +1057,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
mcpResult.actionResult = actionResult
}
// Truncate text preview only if terse=true
// Include text output preview (truncated for readability)
if (output) {
if (isTerse) {
mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "")
} else {
mcpResult.textPreview = output
}
mcpResult.textPreview = output.take(2000) + (output.length() > 2000 ? "..." : "")
}
if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions
......@@ -1414,7 +1379,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
<parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter>
<parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter>
<parameter name="sessionId"/>
<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>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
......@@ -1749,8 +1713,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
path: path,
parameters: parameters ?: [:],
renderMode: actualRenderMode,
sessionId: sessionId,
terse: context.terse == true
sessionId: sessionId
]
// Call ScreenAsMcpTool to render
......@@ -1785,8 +1748,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
def uiNarrative = narrativeBuilder.buildNarrative(
screenDefForNarrative,
resultObj.semanticState,
currentPath,
context.terse == true
currentPath
)
resultMap.uiNarrative = uiNarrative
ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}: ${uiNarrative?.keySet()}")
......@@ -1912,8 +1874,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
"path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"],
"action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"],
"renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"],
"parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"],
"terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"]
"parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"]
]
]
],
......
package org.moqui.mcp
import org.moqui.context.ExecutionContext
import org.moqui.impl.context.ExecutionContextFactoryImpl
import groovy.json.JsonSlurper
/**
* Service for getting screen field details including dropdown options via dynamic-options.
*
* This implementation mirrors how the Moqui web UI handles autocomplete:
* - Uses CustomScreenTestImpl with skipJsonSerialize(true) to call transitions
* - Captures the raw JSON response via getJsonObject()
* - Processes the response to extract options
*
* See ScreenRenderImpl.getFieldOptions() in moqui-framework for the reference implementation.
*/
class McpFieldOptionsService {
static service(String path, String fieldName, Map parameters, ExecutionContext ec) {
if (!path) {
throw new IllegalArgumentException("path is required")
ec.logger.info("======== MCP GetScreenDetails CALLED - CODE VERSION 3 (ScreenTest) =======")
if (!path) throw new IllegalArgumentException("path is required")
ec.logger.info("MCP GetScreenDetails: screen ${path}, field ${fieldName ?: 'all'}")
def result = [screenPath: path, fields: [:]]
try {
def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters([path: path, parameters: parameters ?: [:], renderMode: "mcp", sessionId: null])
.call()
ec.logger.info("=== browseResult: ${browseResult != null}, result exists: ${browseResult?.result != null} ===")
if (!browseResult?.result?.content) {
ec.logger.warn("No content from ScreenAsMcpTool")
return result + [error: "No content from ScreenAsMcpTool"]
}
def rawText = browseResult.result.content[0].text
if (!rawText || !rawText.startsWith("{")) {
ec.logger.warn("Invalid JSON from ScreenAsMcpTool")
return result + [error: "Invalid JSON from ScreenAsMcpTool"]
}
def resultObj = new JsonSlurper().parseText(rawText)
def semanticState = resultObj?.semanticState
def formMetadata = semanticState?.data?.formMetadata
if (!(formMetadata instanceof Map)) {
ec.logger.warn("formMetadata is not a Map: ${formMetadata?.class}")
return result + [error: "No form metadata found"]
}
def allFields = [:]
ec.logger.info("=== Processing formMetadata with ${formMetadata.size()} forms ===")
formMetadata.each { formName, formItem ->
ec.logger.info("=== Processing form: ${formName}, hasFields: ${formItem?.fields != null} ===")
if (!(formItem instanceof Map) || !formItem.fields) return
formItem.fields.each { field ->
if (!(field instanceof Map) || !field.name) return
def fieldInfo = [
name: field.name,
title: field.title,
type: field.type,
required: field.required ?: false
]
if (field.type == "dropdown" && field.options) fieldInfo.options = field.options
def dynamicOptions = field.dynamicOptions
if (dynamicOptions instanceof Map) {
fieldInfo.dynamicOptions = dynamicOptions
ec.logger.info("Found dynamicOptions for field ${field.name}: ${dynamicOptions}")
try {
fetchOptions(fieldInfo, path, parameters, dynamicOptions, ec)
} catch (Exception e) {
ec.logger.warn("Failed to fetch options for ${field.name}: ${e.message}", e)
fieldInfo.optionsError = e.message
}
}
allFields[field.name] = fieldInfo
}
}
if (fieldName) {
if (allFields[fieldName]) result.fields[fieldName] = allFields[fieldName]
else result.error = "Field not found: ${fieldName}"
} else {
result.fields = allFields.collectEntries { k, v -> [k, v] }
}
} catch (Exception e) {
ec.logger.error("MCP GetScreenDetails error: ${e.message}", e)
result.error = e.message
}
return result
}
/**
* Fetch options for a field with dynamic-options by calling the transition.
*
* This uses CustomScreenTestImpl with skipJsonSerialize(true) to call the transition
* and capture the raw JSON response - exactly how ScreenRenderImpl.getFieldOptions() works.
*/
private static void fetchOptions(Map fieldInfo, String path, Map parameters, Map dynamicOptions, ExecutionContext ec) {
ec.logger.info("=== fetchOptions START: ${fieldInfo.name} ===")
def transitionName = dynamicOptions.transition
if (!transitionName) {
ec.logger.info("No transition specified for dynamic options")
return
}
ec.logger.info("MCP GetScreenDetails: Getting details for screen ${path}, field ${fieldName ?: 'all'}")
def optionParams = [:]
def result = [
screenPath: path,
fields: [:]
]
// 1. Handle dependsOn (from form XML) - maps field values to service parameters
if (dynamicOptions.dependsOn) {
def depList = dynamicOptions.dependsOn instanceof String ?
new JsonSlurper().parseText(dynamicOptions.dependsOn) : dynamicOptions.dependsOn
depList.each { dep ->
def parts = dep.split('\\|')
def fld = parts[0], prm = parts.size() > 1 ? parts[1] : fld
def val = parameters?.get(fld)
// Try common form map names if not found at top level
if (val == null) {
['fieldValues', 'fieldValuesMap', 'formValues', 'formValuesMap', 'formMap'].each { mapName ->
def mapVal = parameters?.get(mapName as String)
if (mapVal instanceof Map) {
val = mapVal.get(fld)
if (val != null) return
}
}
}
if (val != null) optionParams[prm] = val
}
}
// 2. Handle serverSearch fields
// If serverSearch is true AND no term is provided, skip fetching (matches framework behavior)
// The framework's getFieldOptions() skips server-search fields entirely for initial load
def isServerSearch = dynamicOptions.serverSearch == true || dynamicOptions.serverSearch == "true"
if (isServerSearch) {
if (parameters?.term != null && parameters.term.toString().length() > 0) {
optionParams.term = parameters.term
} else {
// Skip fetching options for server-search fields without a term
ec.logger.info("Skipping server-search field ${fieldInfo.name} - no term provided")
return
}
}
// 3. Use CustomScreenTestImpl with skipJsonSerialize to call the transition
// This is exactly how ScreenRenderImpl.getFieldOptions() works in the framework
ec.logger.info("Calling transition ${transitionName} via CustomScreenTestImpl with skipJsonSerialize=true, params: ${optionParams}")
try {
// First, render screen to get form metadata (including dynamicOptions)
def browseScreenCallParams = [
path: path,
parameters: parameters ?: [:],
renderMode: "mcp",
sessionId: null,
terse: false
]
def ecfi = (ExecutionContextFactoryImpl) ec.factory
def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters(browseScreenCallParams)
.call()
// Build transition path by appending transition name to screen path
def fullPath = path
if (!fullPath.endsWith('/')) fullPath += '/'
fullPath += transitionName
if (browseResult?.result?.content?.size() > 0) {
def rawText = browseResult.result.content[0].text
if (rawText && rawText.startsWith("{")) {
def resultObj = new groovy.json.JsonSlurper().parseText(rawText)
def semanticData = resultObj?.semanticState?.data
if (semanticData?.containsKey("formMetadata")) {
def formMetadata = semanticData.formMetadata
def allFields = [:]
if (formMetadata instanceof Map) {
formMetadata.each { formName, formItem ->
if (formItem instanceof Map && formItem.containsKey("fields")) {
def fieldList = formItem.fields
if (fieldList instanceof Collection) {
fieldList.each { field ->
if (field instanceof Map && field.containsKey("name")) {
def fieldInfo = [
name: field.name,
title: field.title,
type: field.type,
required: field.required ?: false
]
// Add dropdown options if available (static options)
if (field.type == "dropdown" && field.containsKey("options")) {
fieldInfo.options = field.options
}
// Add dynamic options metadata and actually fetch the options
def skipField = false
if (field.containsKey("dynamicOptions") && !skipField) {
def dynamicOptions = field.dynamicOptions
fieldInfo.dynamicOptions = dynamicOptions
try {
def serviceName = dynamicOptions.containsKey("service") ? dynamicOptions.service : null
def transitionName = dynamicOptions.containsKey("transition") ? dynamicOptions.transition : null
def optionParams = [:]
// Parse inParameterMap if specified (extracted from transition XML)
def inParameterMap = [:]
if (dynamicOptions.containsKey("inParameterMap") && dynamicOptions.inParameterMap && dynamicOptions.inParameterMap.trim()) {
// Parse in-map format: "[target1:source1,target2:source2]"
def mapContent = dynamicOptions.inParameterMap.trim()
if (mapContent.startsWith("[") && mapContent.endsWith("]")) {
def innerContent = mapContent.substring(1, mapContent.length() - 1)
innerContent.split(',').each { mapping ->
def colonIndex = mapping.indexOf(':')
if (colonIndex > 0) {
def targetParam = mapping.substring(0, colonIndex).trim()
def sourceFields = mapping.substring(colonIndex + 1).trim()
// Handle multiple source fields separated by comma
sourceFields.split(',').each { sourceField ->
def sourceValue = parameters?.get(sourceField.trim())
if (sourceValue != null) {
inParameterMap[targetParam] = sourceValue
ec.logger.info("MCP GetScreenDetails: Mapped in-param ${sourceField} -> ${targetParam} = ${sourceValue}")
}
}
}
}
}
}
// Handle depends-on fields and parameter overrides
ec.logger.info("MCP GetScreenDetails: Processing depends-on for field ${field.name}")
// Parse depends-on list (may include parameter overrides like "field|parameter")
def dependsOnFields = []
if (dynamicOptions.containsKey("dependsOn") && dynamicOptions.dependsOn) {
if (dynamicOptions.dependsOn instanceof String) {
dependsOnFields = new groovy.json.JsonSlurper().parseText(dynamicOptions.dependsOn)
} else if (dynamicOptions.dependsOn instanceof List) {
dependsOnFields = dynamicOptions.dependsOn
}
}
// Process each depends-on field with potential parameter override
dependsOnFields.each { depFieldOrTuple ->
def depField = depFieldOrTuple
def depParameter = depFieldOrTuple // Default: use field name as parameter name
// Check if depends-on item is a "field|parameter" tuple
if (depFieldOrTuple instanceof String && depFieldOrTuple.contains("|")) {
def parts = depFieldOrTuple.split("\\|")
if (parts.size() == 2) {
depField = parts[0].trim()
depParameter = parts[1].trim()
}
}
def depValue = parameters?.get(depField)
if (depValue == null) {
ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} has no value in parameters")
} else {
ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} = ${depValue}, targetParam = ${depParameter}")
// Add to optionParams - use depParameter as key if specified, otherwise use depField
optionParams[depParameter ?: depField] = depValue
}
}
// For transitions with web-send-json-response, try to extract and call service directly
// These transitions wrap services like BasicServices.get#GeoRegionsForDropDown
if (dynamicOptions.containsKey("serviceName") && dynamicOptions.serviceName) {
// Direct service call - use extracted service name from transition XML
ec.logger.info("MCP GetScreenDetails: Calling direct service ${dynamicOptions.serviceName} for field ${field.name} with optionParams: ${optionParams}")
def optionsResult = ec.service.sync().name(dynamicOptions.serviceName).parameters(optionParams).call()
if (optionsResult && optionsResult.resultList) {
def optionsList = []
optionsResult.resultList.each { opt ->
if (opt instanceof Map) {
def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
def label = opt.label ?: opt.description ?: opt.value
optionsList << [value: key, label: label]
}
}
if (optionsList) {
fieldInfo.options = optionsList
ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call")
allFields[field.name] = fieldInfo
return // Skip remaining processing for this field
}
}
} else {
// Fallback for hardcoded transitions or when serviceName not available
ec.logger.info("MCP GetScreenDetails: No serviceName found, checking hardcoded transitions")
if (transitionName == "getGeoCountryStates" || transitionName == "getGeoStateCounties") {
def underlyingService = "org.moqui.impl.BasicServices.get#GeoRegionsForDropDown"
// Map depends-on field names to service parameter names (e.g., countryGeoId -> geoId)
def serviceParams = [:]
if (optionParams.containsKey("countryGeoId")) {
serviceParams.geoId = optionParams.countryGeoId
}
if (optionParams.containsKey("stateGeoId")) {
serviceParams.geoId = optionParams.stateGeoId
serviceParams.geoTypeEnumId = "GEOT_COUNTY"
}
if (optionParams.containsKey("term")) {
serviceParams.term = optionParams.term
}
ec.logger.info("MCP GetScreenDetails: Calling direct service ${underlyingService} for field ${field.name} with serviceParams: ${serviceParams}")
def optionsResult = ec.service.sync().name(underlyingService).parameters(serviceParams).call()
if (optionsResult && optionsResult.resultList) {
def optionsList = []
optionsResult.resultList.each { opt ->
if (opt instanceof Map) {
def key = opt.geoId ?: opt.value ?: opt.key
def label = opt.label ?: opt.value
optionsList << [value: key, label: label]
}
}
if (optionsList) {
fieldInfo.options = optionsList
ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call, returning from field processing")
allFields[field.name] = fieldInfo
return // Skip remaining processing for this field
}
}
}
// Fallback for transitions without direct service - try calling via ScreenAsMcpTool
// This handles entity-find transitions and others without web-send-json-response
if (transitionName && !dynamicOptions.containsKey("serviceName")) {
ec.logger.info("MCP GetScreenDetails: Calling transition ${transitionName} via ScreenAsMcpTool for field ${field.name}")
def transCallParams = [
path: "/" + path,
action: transitionName,
parameters: optionParams,
renderMode: "mcp",
sessionId: null
]
def transResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool").parameters(transCallParams).call()
// Parse response - try to extract options from mcp render output
if (transResult?.result?.content?.size() > 0) {
def responseText = transResult.result.content[0].text
if (responseText) {
ec.logger.info("MCP GetScreenDetails: Transition returned ${responseText.length()} chars")
// Try to parse as JSON first
try {
def jsonObj = new groovy.json.JsonSlurper().parseText(responseText)
// Check for options in formMetadata
if (jsonObj.containsKey("formMetadata")) {
def formData = jsonObj.formMetadata
formData.each { formName, formItem ->
if (formItem.containsKey("fields")) {
def fieldOptions = formItem.fields.find { it.name == field.name }
if (fieldOptions && fieldOptions.containsKey("options")) {
fieldInfo.options = fieldOptions.options
ec.logger.info("MCP GetScreenDetails: Retrieved ${fieldInfo.options.size()} options from formMetadata")
allFields[field.name] = fieldInfo
return
}
}
}
}
// Fallback to previous logic
if (jsonObj instanceof Map && jsonObj.containsKey("resultList")) {
def resultList = jsonObj.resultList
if (resultList instanceof List) {
def optionsList = []
resultList.each { opt ->
if (opt instanceof Map) {
def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
def label = opt.label ?: opt.description ?: opt.value
optionsList << [value: key, label: label]
}
}
if (optionsList) {
fieldInfo.options = optionsList
ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options from transition ${transitionName}")
allFields[field.name] = fieldInfo
return
}
} else if (jsonObj instanceof Map && jsonObj.containsKey("options")) {
fieldInfo.options = jsonObj.options
} else if (jsonObj instanceof Map && jsonObj.containsKey("value")) {
fieldInfo.options = [[value: jsonObj.value, label: jsonObj.value]]
} else {
fieldInfo.optionsError = "Unrecognized response format: ${jsonObj.getClass().simpleName}"
}
} catch (Exception parseEx) {
ec.logger.warn("MCP GetScreenDetails: Failed to parse transition response: ${parseEx.message}")
fieldInfo.optionsError = "Transition call succeeded but response format not recognized. Try: moqui_browse_screens(path='${path}', action='${transitionName}')"
}
}
}
fieldInfo.optionsError = "Call moqui_browse_screens(path='\${path}', action='\${transitionName}') to fetch dropdown options"
}
if (serviceName) {
// Direct service call - same as frontend does
ec.logger.info("MCP GetScreenDetails: Calling service ${serviceName} for field ${field.name}")
def optionsResult = ec.service.sync().name(serviceName).parameters(optionParams).call()
if (optionsResult) {
def optionsList = []
if (optionsResult instanceof Map && optionsResult.containsKey("resultList")) {
def resultList = optionsResult.resultList
if (resultList instanceof List) {
resultList.each { opt ->
if (opt instanceof Map) {
def key = opt.geoId ?: opt.value ?: opt.key
def label = opt.label ?: opt.value
optionsList << [value: key, label: label]
}
}
}
} else if (optionsResult instanceof List) {
optionsList = optionsResult
}
if (optionsList) {
fieldInfo.options = optionsList
ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options from service ${serviceName}")
}
}
} else if (transitionName) {
// For transitions, try calling via ScreenAsMcpTool with transition
ec.logger.info("MCP GetScreenDetails: Calling transition ${transitionName} for field ${field.name}")
def transCallParams = [
path: "/" + path,
action: transitionName,
parameters: optionParams,
renderMode: "text", // Get raw text response, not JSON
sessionId: null
]
def transResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool").parameters(transCallParams).call()
// Parse response - transitions return JSON with 'resultList'
if (transResult?.result?.content?.size() > 0) {
def responseText = transResult.result.content[0].text
if (responseText) {
ec.logger.info("MCP GetScreenDetails: Transition returned ${responseText.length()} chars")
// Parse JSON from response
try {
def jsonObj = new groovy.json.JsonSlurper().parseText(responseText)
if (jsonObj instanceof Map && jsonObj.containsKey("resultList")) {
def resultList = jsonObj.resultList
if (resultList instanceof List) {
def optionsList = []
resultList.each { opt ->
if (opt instanceof Map) {
def key = opt.geoId ?: opt.value ?: opt.key
def label = opt.label ?: opt.value
optionsList << [value: key, label: label]
}
}
if (optionsList) {
fieldInfo.options = optionsList
ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options from transition ${transitionName}")
}
}
} else if (jsonObj instanceof Map && jsonObj.containsKey("options")) {
fieldInfo.options = jsonObj.options
} else if (jsonObj instanceof Map && jsonObj.containsKey("value")) {
fieldInfo.options = [[value: jsonObj.value, label: jsonObj.value]]
}
} catch (Exception parseEx) {
ec.logger.warn("MCP GetScreenDetails: Failed to parse transition response: ${parseEx.message}")
fieldInfo.optionsError = "Failed to parse options response"
}
}
}
} else {
fieldInfo.optionsError = "No service or transition specified in dynamic-options"
}
} catch (Exception e) {
ec.logger.warn("MCP GetScreenDetails: Failed to get options for field ${field.name}: ${e.message}")
fieldInfo.optionsError = "Failed to load options: ${e.message}"
}
}
if (!skipField) {
allFields[field.name] = fieldInfo
}
}
}
}
}
}
}
ec.logger.info("MCP GetScreenDetails: Extracted ${allFields.size()} fields")
// Return specific field or all fields
if (fieldName) {
def specificField = allFields[fieldName]
if (specificField) {
result.fields[fieldName] = specificField
} else {
result.error = "Field not found: ${fieldName}"
}
} else {
result.fields = allFields.collectEntries { k, v -> [name: k, *:v] }
}
// Parse path segments for component-based resolution
def pathSegments = []
fullPath.split('/').each { if (it && it.trim()) pathSegments.add(it) }
// Component-based resolution (same as ScreenAsMcpTool)
// Path like "PopCommerce/PopCommerceAdmin/Party/FindParty/transition" becomes:
// - rootScreen: component://PopCommerce/screen/PopCommerceAdmin.xml
// - testScreenPath: Party/FindParty/transition
def rootScreen = "component://webroot/screen/webroot.xml"
def testScreenPath = fullPath
if (pathSegments.size() >= 2) {
def componentName = pathSegments[0]
def rootScreenName = pathSegments[1]
def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml"
if (ec.resource.getLocationReference(compRootLoc).exists) {
ec.logger.info("fetchOptions: Using component root: ${compRootLoc}")
rootScreen = compRootLoc
testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : ""
}
}
// Use CustomScreenTestImpl with skipJsonSerialize - like ScreenRenderImpl.getFieldOptions()
def screenTest = new CustomScreenTestImpl(ecfi)
.rootScreen(rootScreen)
.skipJsonSerialize(true)
.auth(ec.user.username)
ec.logger.info("Rendering transition path: ${testScreenPath} (from root: ${rootScreen})")
def str = screenTest.render(testScreenPath, optionParams, "GET")
// Get JSON object directly (like web UI does)
def jsonObj = str.getJsonObject()
ec.logger.info("Transition returned jsonObj: ${jsonObj?.getClass()?.simpleName}, size: ${jsonObj instanceof Collection ? jsonObj.size() : 'N/A'}")
// Extract value-field and label-field from dynamic-options config
def valueField = dynamicOptions.valueField ?: dynamicOptions.'value-field' ?: 'value'
def labelField = dynamicOptions.labelField ?: dynamicOptions.'label-field' ?: 'label'
// Process the JSON response - same logic as ScreenRenderImpl.getFieldOptions()
List optsList = null
if (jsonObj instanceof List) {
optsList = (List) jsonObj
} else if (jsonObj instanceof Map) {
Map jsonMap = (Map) jsonObj
// Try 'options' key first (standard pattern)
def optionsObj = jsonMap.get("options")
if (optionsObj instanceof List) {
optsList = (List) optionsObj
} else if (jsonMap.get("resultList") instanceof List) {
// Some services return resultList
optsList = (List) jsonMap.get("resultList")
}
}
if (optsList != null && optsList.size() > 0) {
fieldInfo.options = optsList.collect { entryObj ->
if (entryObj instanceof Map) {
Map entryMap = (Map) entryObj
// Try configured fields first, then common fallbacks
def value = entryMap.get(valueField) ?:
entryMap.get('value') ?:
entryMap.get('geoId') ?:
entryMap.get('enumId') ?:
entryMap.get('id') ?:
entryMap.get('key')
def label = entryMap.get(labelField) ?:
entryMap.get('label') ?:
entryMap.get('description') ?:
entryMap.get('name') ?:
entryMap.get('text') ?:
value?.toString()
[value: value, label: label]
} else {
ec.logger.warn("MCP GetScreenDetails: No formMetadata found in semantic state")
result.error = "No form data available"
[value: entryObj, label: entryObj?.toString()]
}
} else {
result.error = "Invalid response from ScreenAsMcpTool"
}
}.findAll { it.value != null }
ec.logger.info("Successfully extracted ${fieldInfo.options.size()} autocomplete options via ScreenTest")
} else {
result.error = "No content returned from ScreenAsMcpTool"
ec.logger.info("No options found in transition response")
// Check if there was output but no JSON (might be an error)
def output = str.getOutput()
if (output && output.length() > 0 && output.length() < 500) {
ec.logger.warn("Transition output (no JSON): ${output}")
}
}
} catch (Exception e) {
ec.logger.error("MCP GetScreenDetails: Error: ${e.getClass().simpleName}: ${e.message}")
result.error = "Screen resolution failed: ${e.message}"
ec.logger.warn("Error calling transition ${transitionName}: ${e.message}", e)
fieldInfo.optionsError = "Transition call failed: ${e.message}"
}
return result
}
}
......
package org.moqui.mcp
import org.moqui.context.ExecutionContext
class McpFieldOptionsService {
static service(String path, String fieldName, Map parameters, ExecutionContext ec) {
if (!path) {
throw new IllegalArgumentException("path is required")
}
ec.logger.info("MCP GetScreenDetails: Getting details for screen ${path}, field ${fieldName ?: 'all'}")
def result = [
screenPath: path,
fields: [:]
]
try {
// First, render screen to get form metadata (including dynamicOptions)
def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters([path: path, parameters: parameters ?: [:], renderMode: "mcp", sessionId: null, terse: false])
.call()
if (browseResult?.result?.content?.size() > 0) {
def rawText = browseResult.result.content[0].text
if (rawText && rawText.startsWith("{")) {
def resultObj = new groovy.json.JsonSlurper().parseText(rawText)
def semanticData = resultObj?.semanticState?.data
if (semanticData?.containsKey("formMetadata")) {
def formMetadata = semanticData.formMetadata
def allFields = [:]
if (formMetadata instanceof Map) {
formMetadata.each { formName, formItem ->
if (formItem instanceof Map && formItem.containsKey("fields")) {
def fieldList = formItem.fields
if (fieldList instanceof Collection) {
fieldList.each { field ->
if (field instanceof Map && field.containsKey("name")) {
def fieldInfo = [
name: field.name,
title: field.title,
type: field.type,
required: field.required ?: false
]
// Add dropdown options if available (static options)
if (field.type == "dropdown" && field.containsKey("options")) {
fieldInfo.options = field.options
}
// Add dynamic options metadata and actually fetch options
if (field.containsKey("dynamicOptions")) {
def dynamicOptions = field.dynamicOptions
fieldInfo.dynamicOptions = dynamicOptions
try {
def serviceName = dynamicOptions.containsKey("serviceName") ? dynamicOptions.serviceName : null
def transitionName = dynamicOptions.containsKey("transition") ? dynamicOptions.transition : null
def optionParams = [:]
// Parse inParameterMap if specified (extracted from transition XML)
def inParameterMap = [:]
if (dynamicOptions.containsKey("inParameterMap") && dynamicOptions.inParameterMap && dynamicOptions.inParameterMap.trim()) {
// Parse in-map format: "[target1:source1,target2:source2]"
def mapContent = dynamicOptions.inParameterMap.trim()
if (mapContent.startsWith("[") && mapContent.endsWith("]")) {
def innerContent = mapContent.substring(1, mapContent.length() - 1)
innerContent.split(',').each { mapping ->
def colonIndex = mapping.indexOf(':')
if (colonIndex > 0) {
def targetParam = mapping.substring(0, colonIndex).trim()
def sourceFields = mapping.substring(colonIndex + 1).trim()
// Handle multiple source fields separated by comma
sourceFields.split(',').each { sourceField ->
def sourceValue = parameters?.get(sourceField.trim())
if (sourceValue != null) {
inParameterMap[targetParam] = sourceValue
ec.logger.info("MCP GetScreenDetails: Mapped in-param ${sourceField} -> ${targetParam} = ${sourceValue}")
}
}
}
}
}
}
// Handle depends-on fields and parameter overrides
ec.logger.info("MCP GetScreenDetails: Processing depends-on for field ${field.name}")
// Parse depends-on list (may include parameter overrides like "field|parameter")
def dependsOnFields = []
if (dynamicOptions.containsKey("dependsOn") && dynamicOptions.dependsOn) {
if (dynamicOptions.dependsOn instanceof String) {
dependsOnFields = new groovy.json.JsonSlurper().parseText(dynamicOptions.dependsOn)
} else if (dynamicOptions.dependsOn instanceof List) {
dependsOnFields = dynamicOptions.dependsOn
}
}
// Process each depends-on field with potential parameter override
dependsOnFields.each { depFieldOrTuple ->
def depField = depFieldOrTuple
def depParameter = depFieldOrTuple // Default: use field name as parameter name
// Check if depends-on item is a "field|parameter" tuple
if (depFieldOrTuple instanceof String && depFieldOrTuple.contains("|")) {
def parts = depFieldOrTuple.split("\\|")
if (parts.size() == 2) {
depField = parts[0].trim()
depParameter = parts[1].trim()
}
}
def depValue = parameters?.get(depField)
if (depValue == null) {
ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} has no value in parameters")
} else {
ec.logger.info("MCP GetScreenDetails: Depends-on field ${depField} = ${depValue}, targetParam = ${depParameter}")
// Add to optionParams - use depParameter as key if specified, otherwise use depField
optionParams[depParameter ?: depField] = depValue
}
}
// For server-search fields, add term parameter
if (dynamicOptions.containsKey("serverSearch") && dynamicOptions.serverSearch) {
if (parameters?.containsKey("term")) {
def searchTerm = parameters.term
if (searchTerm && searchTerm.length() >= (dynamicOptions.minLength ?: 0)) {
optionParams.term = searchTerm
ec.logger.info("MCP GetScreenDetails: Server search term = '${searchTerm}'")
} else {
ec.logger.info("MCP GetScreenDetails: No term provided, will return full list")
}
}
}
// For transitions with web-send-json-response, try to extract and call service directly
// These transitions wrap BasicServices.get#GeoRegionsForDropDown
if (dynamicOptions.containsKey("serviceName") && dynamicOptions.serviceName) {
// Direct service call - use extracted service name from transition XML
ec.logger.info("MCP GetScreenDetails: Calling direct service ${dynamicOptions.serviceName} for field ${field.name} with optionParams: ${optionParams}")
def optionsResult = ec.service.sync().name(dynamicOptions.serviceName).parameters(optionParams).call()
if (optionsResult && optionsResult.resultList) {
def optionsList = []
optionsResult.resultList.each { opt ->
if (opt instanceof Map) {
def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
def label = opt.label ?: opt.description ?: opt.value
optionsList << [value: key, label: label]
}
}
if (optionsList) {
fieldInfo.options = optionsList
ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call")
allFields[field.name] = fieldInfo
return // Skip remaining processing for this field
}
}
} else {
// Fallback for hardcoded transitions or when serviceName not available
ec.logger.info("MCP GetScreenDetails: No serviceName found, checking hardcoded transitions")
if (transitionName == "getGeoCountryStates" || transitionName == "getGeoStateCounties") {
def underlyingService = "org.moqui.impl.BasicServices.get#GeoRegionsForDropDown"
// Map depends-on field names to service parameter names (e.g., countryGeoId -> geoId)
def serviceParams = [:]
if (optionParams.containsKey("countryGeoId")) {
serviceParams.geoId = optionParams.countryGeoId
}
if (optionParams.containsKey("stateGeoId")) {
serviceParams.geoId = optionParams.stateGeoId
serviceParams.geoTypeEnumId = "GEOT_COUNTY"
}
if (optionParams.containsKey("term")) {
serviceParams.term = optionParams.term
}
ec.logger.info("MCP GetScreenDetails: Calling direct service ${underlyingService} for field ${field.name} with serviceParams: ${serviceParams}")
def optionsResult = ec.service.sync().name(underlyingService).parameters(serviceParams).call()
if (optionsResult && optionsResult.resultList) {
def optionsList = []
optionsResult.resultList.each { opt ->
if (opt instanceof Map) {
def key = opt.geoId ?: opt.value ?: opt.key ?: opt.enumId
def label = opt.label ?: opt.description ?: opt.value
optionsList << [value: key, label: label]
}
}
if (optionsList) {
fieldInfo.options = optionsList
ec.logger.info("MCP GetScreenDetails: Retrieved ${optionsList.size()} options via direct service call")
allFields[field.name] = fieldInfo
return // Skip remaining processing for this field
}
}
}
}
} catch (Exception e) {
ec.logger.warn("MCP GetScreenDetails: Failed to get options for field ${field.name}: ${e.message}")
fieldInfo.optionsError = "Failed to load options: ${e.message}"
}
}
allFields[field.name] = fieldInfo
}
}
}
}
}
}
ec.logger.info("MCP GetScreenDetails: Extracted ${allFields.size()} fields")
// Return specific field or all fields
if (fieldName) {
def specificField = allFields[fieldName]
if (specificField) {
result.fields[fieldName] = specificField
} else {
result.error = "Field not found: ${fieldName}"
}
} else {
result.fields = allFields.collectEntries { k, v -> [name: k, *:v] }
}
} else {
ec.logger.warn("MCP GetScreenDetails: No formMetadata found in semantic state")
result.error = "No form data available"
}
} else {
result.error = "Invalid response from ScreenAsMcpTool"
}
} catch (Exception e) {
ec.logger.error("MCP GetScreenDetails: Error: ${e.getClass().simpleName}: ${e.message}")
result.error = "Screen resolution failed: ${e.message}"
}
}
return result
}
}
......@@ -46,18 +46,18 @@ class UiNarrativeBuilder {
return count
}
Map<String, Object> buildNarrative(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) {
Map<String, Object> buildNarrative(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath) {
def narrative = [:]
narrative.screen = describeScreen(screenDef, semanticState, isTerse)
narrative.actions = describeActions(screenDef, semanticState, currentPath, isTerse)
narrative.navigation = describeLinks(semanticState, currentPath, isTerse)
narrative.notes = describeNotes(semanticState, currentPath, isTerse)
narrative.screen = describeScreen(screenDef, semanticState)
narrative.actions = describeActions(screenDef, semanticState, currentPath)
narrative.navigation = describeLinks(semanticState, currentPath)
narrative.notes = describeNotes(semanticState, currentPath)
return narrative
}
String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState, boolean isTerse) {
String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState) {
def screenName = screenDef?.getScreenName() ?: "Screen"
def sb = new StringBuilder()
......@@ -103,7 +103,7 @@ class UiNarrativeBuilder {
return sb.toString()
}
List<String> describeActions(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) {
List<String> describeActions(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath) {
def actions = []
def transitions = semanticState?.actions
......@@ -161,7 +161,7 @@ class UiNarrativeBuilder {
return 'screen-transition'
}
List<String> describeLinks(Map<String, Object> semanticState, String currentPath, boolean isTerse) {
List<String> describeLinks(Map<String, Object> semanticState, String currentPath) {
def navigation = []
def links = semanticState?.data?.links
......@@ -201,7 +201,7 @@ class UiNarrativeBuilder {
return navigation
}
List<String> describeNotes(Map<String, Object> semanticState, String currentPath, boolean isTerse) {
List<String> describeNotes(Map<String, Object> semanticState, String currentPath) {
def notes = []
def data = semanticState?.data
......@@ -210,7 +210,7 @@ class UiNarrativeBuilder {
if (value instanceof Map && value.containsKey('_truncated') && value._truncated == true) {
def total = value._totalCount ?: 0
def shown = value._items?.size() ?: 0
notes << "List truncated: showing ${shown} of ${total} item${total > 1 ? 's' : ''}. Set terse=false to view all."
notes << "List truncated: showing ${shown} of ${total} item${total > 1 ? 's' : ''}. Use pagination for more."
}
}
}
......
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*/
package org.moqui.mcp.test
import org.moqui.Moqui
import org.moqui.context.ExecutionContext
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.Stepwise
@Stepwise
class AutocompleteTest extends Specification {
@Shared ExecutionContext ec
@Shared SimpleMcpClient client
def setupSpec() {
ec = Moqui.getExecutionContext()
client = new SimpleMcpClient()
client.initializeSession()
// Log in to ensure permissions
ec.user.internalLoginUser("john.doe")
}
def cleanupSpec() {
if (client) client.closeSession()
if (ec) ec.destroy()
}
def "Test getCategoryList Autocomplete"() {
when:
println "🔍 Testing getCategoryList autocomplete on Search screen"
// 1. Get screen details to find the field and transition
def details = client.callTool("moqui_get_screen_details", [
path: "component://SimpleScreens/screen/SimpleScreens/Catalog/Search.xml",
fieldName: "productCategoryId"
])
println "📋 Screen details result: ${details}"
then:
details != null
!details.error
!details.result?.error
def content = details.result?.content
content != null
content.size() > 0
// Parse the text content from the response
def jsonText = content[0].text
def jsonResult = new groovy.json.JsonSlurper().parseText(jsonText)
def field = jsonResult.fields?.productCategoryId
field != null
field.dynamicOptions != null
field.dynamicOptions.transition == "getCategoryList"
field.dynamicOptions.serverSearch == true
println "✅ Field metadata verified: ${field.dynamicOptions}"
when:
println "🔍 Testing explicit transition call via TransitionAsMcpTool"
// 2. Call the transition directly to simulate typing
def transitionResult = ec.service.sync().name("McpServices.execute#TransitionAsMcpTool")
.parameters([
path: "component://SimpleScreens/screen/SimpleScreens/Catalog/Search.xml",
transitionName: "getCategoryList",
parameters: [term: ""]
])
.call()
println "🔄 Transition result: ${transitionResult}"
then:
transitionResult != null
transitionResult.result != null
!transitionResult.result.error
transitionResult.result.data != null
transitionResult.result.data instanceof List
transitionResult.result.data.size() > 0
println "✅ Found ${transitionResult.result.data.size()} categories via transition"
println "📝 First category: ${transitionResult.result.data[0]}"
}
}
......@@ -155,6 +155,23 @@ class SimpleMcpClient {
}
/**
* Call any tool
*/
Map callTool(String toolName, Map arguments = [:]) {
try {
def result = makeJsonRpcRequest("tools/call", [
name: toolName,
arguments: arguments
])
return result ?: [error: [message: "No response from server"]]
} catch (Exception e) {
println "Error calling tool ${toolName}: ${e.message}"
return [error: [message: e.message]]
}
}
/**
* Call a screen tool
*/
Map callScreen(String screenPath, Map parameters = [:]) {
......