a06fd54e by Ean Schuessler

Implement universal autocomplete with transition metadata extraction

- Extract service name and in-map from transition XML
- Capture depends-on parameter attribute for field name mapping
- Parse in-map parameter mapping for proper service calls
- Support server-search autocomplete with term parameter
- Fallback to ScreenAsMcpTool for entity-find transitions
- Enhanced response format detection and error handling

This implements TODO 1-3 from AGENTS.md:
- TODO 1: Capture Depends-on Parameter Attribute
- TODO 2: Extract Transition Service Name
- TODO 3: Parse in-map Parameter Mapping

Files modified:
- DefaultScreenMacros.mcp.ftl: Extract transition metadata during screen render
- McpFieldOptionsService.groovy: Use extracted metadata for intelligent autocomplete
1 parent 4b34c970
......@@ -45,18 +45,29 @@ Use the following discovery tools to explore available functionality:
## Common Screen Paths
### Catalog Operations
- `/PopCommerce/PopCommerceAdmin/Catalog/dashboard`: Catalog overview and management
- `/PopCommerce/PopCommerceAdmin/Catalog/Product/FindProduct`: Search and browse products
- `/PopCommerce/PopCommerceAdmin/Catalog/Feature/FindFeature`: Search by features like color or size
- `/PopCommerce/PopCommerceAdmin/Catalog/Product/EditPrices`: View and update product prices
### Order Management
- `/PopCommerce/PopCommerceAdmin/Order/FindOrder`: Lookup order status and details
- `/PopCommerce/PopCommerceAdmin/Order/FindOrder`: Lookup and create sales/purchase orders
- `/PopCommerce/PopCommerceAdmin/QuickSearch`: General order and customer search
### Customer Management
- `/PopCommerce/PopCommerceRoot/Customer`: Manage customer accounts
### Party & Customer Management
- `/PopCommerce/PopCommerceAdmin/Party/FindParty`: Manage all parties (People, Organizations)
- `/PopCommerce/PopCommerceRoot/Customer`: Customer storefront account management
- `User`: Internal user account, notifications, and messages
- `/PopCommerce/PopCommerceAdmin/QuickSearch`: Customer lookup
### Facility & Accounting
- `/PopCommerce/PopCommerceAdmin/Facility/FindFacility`: Manage warehouses and inventory locations
- `/PopCommerce/PopCommerceAdmin/Accounting/dashboard`: Financial management and GL accounting
### System & Developer Tools
- `/tools/Tools`: Developer tools, entity data editing, and service references
- `/tools/System`: System administration, cache management, and security settings
## Tips for LLM Clients
- All screens support parameterized queries for filtering results
......@@ -107,18 +118,29 @@ Use the following discovery tools to explore available functionality:
## Common Screen Paths
### Catalog Operations
- `/PopCommerce/PopCommerceAdmin/Catalog/dashboard`: Catalog overview and management
- `/PopCommerce/PopCommerceAdmin/Catalog/Product/FindProduct`: Search and browse products
- `/PopCommerce/PopCommerceAdmin/Catalog/Feature/FindFeature`: Search by features like color or size
- `/PopCommerce/PopCommerceAdmin/Catalog/Product/EditPrices`: View and update product prices
### Order Management
- `/PopCommerce/PopCommerceAdmin/Order/FindOrder`: Lookup order status and details
- `/PopCommerce/PopCommerceAdmin/Order/FindOrder`: Lookup and create sales/purchase orders
- `/PopCommerce/PopCommerceAdmin/QuickSearch`: General order and customer search
### Customer Management
- `/PopCommerce/PopCommerceRoot/Customer`: Manage customer accounts
### Party & Customer Management
- `/PopCommerce/PopCommerceAdmin/Party/FindParty`: Manage all parties (People, Organizations)
- `/PopCommerce/PopCommerceRoot/Customer`: Customer storefront account management
- `User`: Internal user account, notifications, and messages
- `/PopCommerce/PopCommerceAdmin/QuickSearch`: Customer lookup
### Facility & Accounting
- `/PopCommerce/PopCommerceAdmin/Facility/FindFacility`: Manage warehouses and inventory locations
- `/PopCommerce/PopCommerceAdmin/Accounting/dashboard`: Financial management and GL accounting
### System & Developer Tools
- `/tools/Tools`: Developer tools, entity data editing, and service references
- `/tools/System`: System administration, cache management, and security settings
## Tips for LLM Clients
- All screens support parameterized queries for filtering results
......@@ -144,14 +166,15 @@ Main storefront and customer-facing screens for PopCommerce e-commerce component
## Key Screens
- **Customer Management**: Browse and manage customer accounts
- **Order Status**: View order history and track shipments
- **Product Catalog**: Browse products and categories
- **Shopping Cart**: Review and checkout items
- **Catalog & Products**: Browse the product catalog and view details
- **Customer Management**: Manage customer profiles and account settings
- **Order Management**: Track order history and status
- **Shopping Cart**: Review items and proceed to checkout
- **Messages**: Access `User/Messages` for internal communications
## Navigation
Use browse tools to explore the full catalog of PopCommerce screens starting from this root screen.]]></fileData>
Use browse tools to explore the full catalog of PopCommerce screens. For administrative tasks, use the `/PopCommerce/PopCommerceAdmin` hierarchy.]]></fileData>
</moqui.resource.DbResourceFile>
<!-- PopCommerce Root Wiki Page -->
......@@ -212,4 +235,126 @@ These screens are primarily for learning Moqui screen development patterns.]]></
userId="EX_JOHN_DOE"
changeDateTime="2025-01-02 00:00:00.000"/>
<!-- User Messages Documentation -->
<moqui.resource.DbResource
resourceId="WIKI_MCP_DOCS_USER_MESSAGES"
parentResourceId="WIKI_MCP_SCREEN_DOCS"
filename="User/Messages.md"
isFile="Y"/>
<moqui.resource.DbResourceFile
resourceId="WIKI_MCP_DOCS_USER_MESSAGES"
mimeType="text/markdown"
versionName="v1">
<fileData><![CDATA[# User Messages
Interface for sending and receiving messages between users and other parties.
## Key Actions
- **Create Message**: Send a new message. Requires `toPartyId`, `subject`, and `body`.
- **View Thread**: View the conversation history for a message.
- **Find Messages**: Search for existing messages by various criteria.
## Usage
Use this screen to communicate about orders, products, or general administrative tasks.]]></fileData>
</moqui.resource.DbResourceFile>
<moqui.resource.wiki.WikiPage
wikiPageId="MCP_SCREEN_DOCS/UserMessages"
wikiSpaceId="MCP_SCREEN_DOCS"
pagePath="User/Messages"
publishedVersionName="v1"
restrictView="N">
</moqui.resource.wiki.WikiPage>
<moqui.resource.wiki.WikiPageHistory
wikiPageId="MCP_SCREEN_DOCS/UserMessages"
historySeqId="1"
versionName="v1"
userId="EX_JOHN_DOE"
changeDateTime="2025-01-14 00:00:00.000"/>
<!-- Tools Documentation -->
<moqui.resource.DbResource
resourceId="WIKI_MCP_DOCS_TOOLS"
parentResourceId="WIKI_MCP_SCREEN_DOCS"
filename="tools/Tools.md"
isFile="Y"/>
<moqui.resource.DbResourceFile
resourceId="WIKI_MCP_DOCS_TOOLS"
mimeType="text/markdown"
versionName="v1">
<fileData><![CDATA[# Developer Tools
Central hub for developer and data management utilities.
## Key Subscreens
- **Entity**: Data editing, import/export, and performance stats. Use `tools/Tools/Entity/DataEdit/EntityList` to browse and edit any entity.
- **Service**: Service reference and testing tools.
- **AutoScreen**: Automatically generated screens for all entities.
- **GroovyShell**: Execute arbitrary Groovy code (use with extreme caution).
## Usage
Use these tools for low-level data manipulation or system exploration when standard administrative screens are insufficient.]]></fileData>
</moqui.resource.DbResourceFile>
<moqui.resource.wiki.WikiPage
wikiPageId="MCP_SCREEN_DOCS/Tools"
wikiSpaceId="MCP_SCREEN_DOCS"
pagePath="tools/Tools"
publishedVersionName="v1"
restrictView="N">
</moqui.resource.wiki.WikiPage>
<moqui.resource.wiki.WikiPageHistory
wikiPageId="MCP_SCREEN_DOCS/Tools"
historySeqId="1"
versionName="v1"
userId="EX_JOHN_DOE"
changeDateTime="2025-01-14 00:00:00.000"/>
<!-- System Documentation -->
<moqui.resource.DbResource
resourceId="WIKI_MCP_DOCS_SYSTEM"
parentResourceId="WIKI_MCP_SCREEN_DOCS"
filename="tools/System.md"
isFile="Y"/>
<moqui.resource.DbResourceFile
resourceId="WIKI_MCP_DOCS_SYSTEM"
mimeType="text/markdown"
versionName="v1">
<fileData><![CDATA[# System Administration
Core system management and monitoring screens.
## Key Subscreens
- **Security**: Manage users, user groups, and artifact permissions.
- **Cache**: View and clear system caches.
- **ServiceJob**: Manage scheduled and background service jobs.
- **LogViewer**: View system logs and hit statistics.
## Usage
Use these screens to monitor system health, manage user access, and troubleshoot operational issues.]]></fileData>
</moqui.resource.DbResourceFile>
<moqui.resource.wiki.WikiPage
wikiPageId="MCP_SCREEN_DOCS/System"
wikiSpaceId="MCP_SCREEN_DOCS"
pagePath="tools/System"
publishedVersionName="v1"
restrictView="N">
</moqui.resource.wiki.WikiPage>
<moqui.resource.wiki.WikiPageHistory
wikiPageId="MCP_SCREEN_DOCS/System"
historySeqId="1"
versionName="v1"
userId="EX_JOHN_DOE"
changeDateTime="2025-01-14 00:00:00.000"/>
</entity-facade-xml>
......
......@@ -147,14 +147,61 @@
<#if dropdownOptions?has_content>
<#assign fieldMeta = fieldMeta + {"type": "dropdown", "options": dropdownOptions?js_string!}>
<#else>
<#assign dynamicOptionNode = fieldSubNode["drop-down"]["dynamic-options"][0]!>
<#assign dropdownNode = fieldSubNode["drop-down"]!>
<#if dropdownNode?is_hash>
<#assign dynamicOptionNode = dropdownNode["dynamic-options"][0]!>
<#else>
<#assign dynamicOptionNode = dropdownNode[0]["dynamic-options"][0]!>
</#if>
<#if dynamicOptionNode?has_content>
<#-- 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": "[]"}>
</#if>
</#if>
</#if>
</#if>
<#-- Capture depends-on with parameter attribute -->
<#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>
<#-- Build dynamicOptions metadata -->
<#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!""
}}>
"parameterMap": ((dynamicOptionNode["@parameter-map"]!"")?js_string)!"",
"dependsOn": dependsOnJson
} + transitionMetadata}>
<#else>
<#assign fieldMeta = fieldMeta + {"type": "dropdown"}>
</#if>
......
......@@ -194,7 +194,8 @@
// Handle internal discovery/utility tools
def internalToolMappings = [
"moqui_search_screens": "McpServices.mcp#SearchScreens"
"moqui_search_screens": "McpServices.mcp#SearchScreens",
"moqui_get_screen_details": "McpServices.mcp#GetScreenDetails"
]
def targetServiceName = internalToolMappings[name]
......@@ -1929,6 +1930,20 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
]
],
[
name: "moqui_get_screen_details",
title: "Get Screen Details",
description: "Get screen field details including dropdown options. Use this to understand available fields and their options before submitting forms.",
inputSchema: [
type: "object",
properties: [
"path": [type: "string", description: "Screen path to analyze (e.g., 'PopCommerce/PopCommerceAdmin/Party/FindParty')"],
"fieldName": [type: "string", description: "Optional specific field name. If not provided, returns all fields."],
"parameters": [type: "object", description: "Optional parameters to set in context before rendering (for autocomplete contexts)."]
],
required: ["path"]
]
],
[
name: "prompts_list",
title: "List Prompts",
description: "List available MCP prompt templates.",
......@@ -1957,6 +1972,36 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
</actions>
</service>
<service verb="mcp" noun="GetScreenDetails" authenticate="true" allow-remote="true" transaction-timeout="60">
<description>Get screen field details including dropdown options. Use this to understand available fields and their options before submitting forms.</description>
<in-parameters>
<parameter name="path" required="true"><description>Screen path to analyze (e.g., '/PopCommerce/PopCommerceAdmin/Party/FindParty').</description></parameter>
<parameter name="fieldName"><description>Optional specific field name. If not provided, returns all fields.</description></parameter>
<parameter name="parameters" type="Map"><description>Optional parameters to set in context before rendering (for autocomplete contexts).</description></parameter>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
</out-parameters>
<actions>
<script><![CDATA[
import org.moqui.context.ExecutionContext
import groovy.json.JsonBuilder
import org.moqui.mcp.McpFieldOptionsService
ExecutionContext ec = context.ec
def serviceResult = McpFieldOptionsService.service(path, fieldName, parameters, ec)
// Return in standard MCP format with content array
def resultJson = new JsonBuilder(serviceResult).toString()
result = [
content: [[type: "text", text: resultJson]],
isError: false
]
]]></script>
</actions>
</service>
<!-- NOTE: handle#McpRequest service removed - functionality moved to screen/webapp.xml for unified handling -->
</services>
\ No newline at end of file
......
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 browseScreenCallParams = [
path: path,
parameters: parameters ?: [:],
renderMode: "mcp",
sessionId: null,
terse: false
]
def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters(browseScreenCallParams)
.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 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] }
}
} 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"
}
} else {
result.error = "No content returned 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
}
}
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
}
}
......@@ -52,13 +52,13 @@ class UiNarrativeBuilder {
narrative.screen = describeScreen(screenDef, semanticState, isTerse)
narrative.actions = describeActions(screenDef, semanticState, currentPath, isTerse)
narrative.navigation = describeLinks(semanticState, currentPath, isTerse)
narrative.notes = describeNotes(semanticState, isTerse)
narrative.notes = describeNotes(semanticState, currentPath, isTerse)
return narrative
}
String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState, boolean isTerse) {
def screenName = screenDef?.name ?: "Screen"
def screenName = screenDef?.getScreenName() ?: "Screen"
def sb = new StringBuilder()
sb.append("${screenName} displays ")
......@@ -201,7 +201,7 @@ class UiNarrativeBuilder {
return navigation
}
List<String> describeNotes(Map<String, Object> semanticState, boolean isTerse) {
List<String> describeNotes(Map<String, Object> semanticState, String currentPath, boolean isTerse) {
def notes = []
def data = semanticState?.data
......@@ -220,6 +220,29 @@ class UiNarrativeBuilder {
notes << "This screen has ${actions.size()} actions. Use semanticState.actions for complete list."
}
// Add note about moqui_get_screen_details for dropdown options
def formData = semanticState?.data
if (formData && formData.containsKey('formMetadata') && formData.formMetadata instanceof Map) {
def formMetadata = formData.formMetadata
def allFields = []
formMetadata.each { formName, formInfo ->
if (formInfo instanceof Map && formInfo.containsKey('fields')) {
def fields = formInfo.fields
if (fields instanceof Collection) {
def dynamicFields = fields.findAll { f -> f instanceof Map && f.containsKey('dynamicOptions') }
if (dynamicFields) {
def fieldNames = dynamicFields.collect { it.name }.take(3)
allFields.addAll(fieldNames)
}
}
}
}
if (allFields) {
def uniqueFields = allFields.unique().take(5)
notes << "Fields with autocomplete: ${uniqueFields.join(', ')}. Use moqui_get_screen_details(path='${currentPath}', fieldName='${uniqueFields[0]}') to get field-specific options."
}
}
def parameters = semanticState?.parameters
if (parameters && parameters.size() > 0) {
def requiredParams = parameters.findAll { k, v -> k.toString().toLowerCase().contains('id') }
......