09883cfe by Ean Schuessler

Add UI narrative for LLM action guidance and unify render modes

- Create UiNarrativeBuilder class for structured, story-like UI descriptions
  - screen: 50-80 words describing current state
  - actions: 80-120 words with exact tool invocation examples
  - navigation: 30-40 words for navigating to related screens
  - notes: 30-50 words for truncation, permissions, constraints

- Enhance mcp.ftl macros to capture semantic metadata
  - form-single: Track field names, types, validation rules
  - form-list: Capture totalItems, displayedItems, truncated flags
  - Store metadata in mcpSemanticData for narrative generation

- Deprecate DefaultScreenMacros.json.ftl
  - Update MoquiConf.xml to map both mcp and json to mcp.ftl
  - Remove redundant 218-line template with no semantic capture

- Integrate uiNarrative into BrowseScreens service
  - Generate narrative using UiNarrativeBuilder
  - Include uiNarrative in result map
  - Provide screenDef for context

- Remove redundant semantic state extraction
  - Delete fallback logic that extracted forms/lists from screen definition
  - Rely exclusively on mcpSemanticData captured by macros

- Improve smart truncation in serializeMoquiObject
  - terse mode: 10 items, 200 char strings
  - non-terse mode: 50 items, no string truncation (fixed bug)
  - Add _hasMore flag to truncated data metadata

- Fix CustomScreenTestImpl postRenderContext capture
  - Capture context before pop to preserve mcpSemanticData
1 parent 61c34e23
1 #Wed Nov 26 15:36:44 CST 2025 1 #Fri Jan 02 17:15:36 CST 2026
2 gradle.version=8.9 2 gradle.version=9.2.0
......
...@@ -2,7 +2,7 @@ arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/o ...@@ -2,7 +2,7 @@ arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/o
2 auto.sync=false 2 auto.sync=false
3 build.scans.enabled=false 3 build.scans.enabled=false
4 connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9)) 4 connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
5 connection.project.dir= 5 connection.project.dir=../../../framework
6 eclipse.preferences.version=1 6 eclipse.preferences.version=1
7 gradle.user.home= 7 gradle.user.home=
8 java.home=/usr/lib/jvm/java-21-openjdk-amd64 8 java.home=/usr/lib/jvm/java-21-openjdk-amd64
......
...@@ -28,10 +28,11 @@ ...@@ -28,10 +28,11 @@
28 </webapp-list> 28 </webapp-list>
29 29
30 <screen-facade> 30 <screen-facade>
31 <!-- DEPRECATED: json mode now uses mcp template for unified semantic capture -->
31 <screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true" 32 <screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true"
32 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/> 33 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/>
33 <screen-text-output type="json" mime-type="application/json" always-standalone="true" 34 <screen-text-output type="json" mime-type="application/json" always-standalone="true"
34 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.json.ftl"/> 35 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/>
35 <widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> 36 <widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/>
36 <widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> 37 <widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/>
37 </screen-facade> 38 </screen-facade>
......
1 <#--
2 Moqui MCP JSON Macros
3 Renders screens in structured JSON format for LLM consumption.
4 -->
5
6 <#include "DefaultScreenMacros.any.ftl"/>
7
8 <#macro @element></#macro>
9
10 <#macro screen>{"screen": {<#recurse>}}</#macro>
11
12 <#macro widgets>
13 "widgets": [<#recurse>]
14 </#macro>
15
16 <#macro "fail-widgets"><#recurse></#macro>
17
18 <#-- ================ Subscreens ================ -->
19 <#macro "subscreens-menu"></#macro>
20 <#macro "subscreens-active">{"type": "subscreens-active", "content": ${sri.renderSubscreen()}}</#macro>
21 <#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro>
22
23 <#-- ================ Section ================ -->
24 <#macro section>{"type": "section", "name": ${(.node["@name"]!"")?json_string}, "content": ${sri.renderSection(.node["@name"])}}</#macro>
25 <#macro "section-iterate">${sri.renderSection(.node["@name"])}</#macro>
26 <#macro "section-include">${sri.renderSectionInclude(.node)}</#macro>
27
28 <#-- ================ Containers ================ -->
29 <#macro container>
30 <#assign children = []>
31 <#list .node?children as child>
32 <#assign rendered><#recurse child></#assign>
33 <#if rendered?has_content && !(rendered?starts_with("{\"widgets\""))>
34 <#assign children = children + [rendered]>
35 </#if>
36 </#list>
37 {"type": "container", "children": [${children?join(",")}]}
38 </#macro>
39
40 <#macro "container-box">
41 {"type": "container-box"<#if .node["box-header"]?has_content>, "header": ${.node["box-header"][0]["@label"]!?json_string}</#if><#if .node["box-body"]?has_content>, "body": ${.node["box-body"][0]["@label"]!?json_string}</#if>}
42 </#macro>
43
44 <#macro "container-row"><#list .node["row-col"] as rowColNode><#recurse rowColNode></#list></#macro>
45
46 <#macro "container-panel">
47 {"type": "container-panel"<#if .node["panel-header"]?has_content>, "header": ${.node["panel-header"][0]["@label"]!?json_string}</#if>}
48 </#macro>
49
50 <#macro "container-dialog">
51 {"type": "container-dialog", "buttonText": ${ec.resource.expand(.node["@button-text"], "")?json_string}}
52 </#macro>
53
54 <#-- ================== Standalone Fields ==================== -->
55 <#macro link>
56 <#assign linkNode = .node>
57 <#if linkNode["@condition"]?has_content><#assign conditionResult = ec.getResource().condition(linkNode["@condition"], "")><#else><#assign conditionResult = true></#if>
58 <#if conditionResult>
59 <#assign urlInstance = sri.makeUrlByType(linkNode["@url"]!"", linkNode["@url-type"]!"transition", linkNode, "true")>
60 <#assign linkText = "">
61 <#if linkNode["@text"]?has_content>
62 <#assign linkText = ec.getResource().expand(linkNode["@text"], "")>
63 <#elseif linkNode["@entity-name"]?has_content>
64 <#assign linkText = sri.getFieldEntityValue(linkNode)!""?string>
65 </#if>
66 <#if !(linkText?has_content) && .node?parent?node_name?ends_with("-field")>
67 <#assign linkText = sri.getFieldValueString(.node?parent?parent)!>
68 </#if>
69
70 <#-- Convert path to dot notation for moqui_render_screen -->
71 <#assign fullPath = urlInstance.sui.fullPathNameList![]>
72 <#assign dotPath = "">
73 <#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list>
74
75 <#assign paramStr = urlInstance.getParameterString()!"">
76 <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if>
77
78 {"type": "link", "text": ${linkText?json_string}, "path": ${dotPath?json_string}}
79 </#if>
80 </#macro>
81
82 <#macro image>{"type": "image", "alt": ${(.node["@alt"]!"")?json_string}, "url": ${(.node["@url"]!"")?json_string}}</#macro>
83
84 <#macro label>
85 <#assign text = ec.resource.expand(.node["@text"], "")>
86 <#assign type = .node["@type"]!"span">
87 {"type": "label", "text": ${text?json_string}, "labelType": ${type?json_string}}
88 </#macro>
89
90 <#-- ======================= Form ========================= -->
91 <#macro "form-single">
92 <#assign formNode = sri.getFormNode(.node["@name"])>
93 <#assign mapName = formNode["@map"]!"fieldValues">
94
95 <#assign fields = []>
96 <#t>${sri.pushSingleFormMapContext(mapName)}
97 <#list formNode["field"] as fieldNode>
98 <#assign fieldSubNode = "">
99 <#list fieldNode["conditional-field"] as csf><#if ec.resource.condition(csf["@condition"], "")><#assign fieldSubNode = csf><#break></#if></#list>
100 <#if !(fieldSubNode?has_content)><#assign fieldSubNode = fieldNode["default-field"][0]!></#if>
101 <#if fieldSubNode?has_content && !(fieldSubNode["ignored"]?has_content) && !(fieldSubNode["hidden"]?has_content) && !(fieldSubNode["submit"]?has_content) && fieldSubNode?parent["@hide"]! != "true">
102 <#assign fieldValue = ec.context.get(fieldSubNode?parent["@name"])!"">
103 <#if fieldValue?has_content>
104 <#assign fieldInfo = {"name": (fieldSubNode?parent["@name"]!"")?json_string, "value": (fieldValue!?json_string)}>
105 <#assign fields = fields + [fieldInfo]>
106 </#if>
107 </#if>
108 </#list>
109 <#t>${sri.popContext()}
110 {"type": "form-single", "name": ${formNode["@name"]?json_string}, "map": ${mapName?json_string}, "fields": [${fields?join(",")}]}
111 </#macro>
112
113 <#macro "form-list">
114 <#assign formInstance = sri.getFormInstance(.node["@name"])>
115 <#assign formListInfo = formInstance.makeFormListRenderInfo()>
116 <#assign formNode = formListInfo.getFormNode()>
117 <#assign formListColumnList = formListInfo.getAllColInfo()>
118 <#assign listObject = formListInfo.getListObject(false)!>
119
120 {"type": "form-list", "name": ${.node["@name"]?json_string}}
121 </#macro>
122
123 <#macro formListSubField fieldNode>
124 <#list fieldNode["conditional-field"] as fieldSubNode>
125 <#if ec.resource.condition(fieldSubNode["@condition"], "")>
126 {"type": "field", "name": ${fieldSubNode["@name"]?json_string}}
127 <#return>
128 </#if>
129 </#list>
130 </#macro>
131
132 <#macro formListWidget fieldSubNode>
133 <#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if>
134 <#if fieldSubNode["submit"]?has_content>
135 <#assign submitText = sri.getFieldValueString(fieldSubNode)!""?json_string>
136 <#assign screenName = sri.getEffectiveScreen().name!""?string>
137 <#assign formNodeObj = sri.getFormNode(.node["@name"])!"">
138 <#assign formName = formNodeObj["@name"]!?string>
139 <#assign fieldName = fieldSubNode["@name"]!""?string>
140 {"type": "submit", "text": ${submitText}, "action": "${screenName}.${formName}.${fieldName}"}
141 </#if>
142 <#recurse fieldSubNode>
143 </#macro>
144
145 <#macro fieldTitle fieldSubNode>
146 <#assign titleValue><#if fieldSubNode["@title"]?has_content>${fieldSubNode["@title"]}<#else><#list fieldSubNode?parent["@name"]?split("(?=[A-Z])", "r") as nameWord>${nameWord?cap_first?replace("Id", "ID")}<#if nameWord_has_next> </#if></#list></#if></#assign>
147 ${ec.l10n.localize(titleValue)?json_string}
148 </#macro>
149
150 <#-- ================== Form Field Widgets ==================== -->
151 <#macro "check">
152 <#assign options = sri.getFieldOptions(.node)!>
153 <#assign currentValue = sri.getFieldValueString(.node)!"">
154 {"type": "check", "value": ${(options.get(currentValue)!currentValue)?json_string}}
155 </#macro>
156
157 <#macro "date-find"></#macro>
158
159 <#macro "date-time">
160 <#assign javaFormat = .node["@format"]!"">
161 <#if !(javaFormat?has_content)>
162 <#if .node["@type"]! == "time"><#assign javaFormat="HH:mm">
163 <#elseif .node["@type"]! == "date"><#assign javaFormat="yyyy-MM-dd">
164 <#else><#assign javaFormat="yyyy-MM-dd HH:mm"></#if>
165 </#if>
166 <#assign fieldValue = sri.getFieldValueString(.node?parent?parent, .node["@default-value"]!"", javaFormat)!"">
167 {"type": "date-time", "name": ${(.node["@name"]!"")?json_string}, "format": ${javaFormat?json_string}, "value": ${fieldValue?json_string!"null"}}
168 </#macro>
169
170 <#macro "display">
171 <#assign fieldValue = "">
172 <#assign dispFieldNode = .node?parent?parent>
173 <#if .node["@text"]?has_content>
174 <#assign textMap = {}>
175 <#if .node["@text-map"]?has_content><#assign textMap = ec.getResource().expression(.node["@text-map"], {})!></#if>
176 <#assign fieldValue = ec.getResource().expand(.node["@text"], "", textMap, false)!>
177 <#if .node["@currency-unit-field"]?has_content>
178 <#assign fieldValue = ec.getL10n().formatCurrency(fieldValue, ec.getResource().expression(.node["@currency-unit-field"], ""))!"">
179 </#if>
180 <#else>
181 <#assign fieldValue = sri.getFieldValueString(.node)!"">
182 </#if>
183 {"type": "display", "value": ${fieldValue?json_string}}
184 </#macro>
185
186 <#macro "display-entity">
187 <#assign entityValue = sri.getFieldEntityValue(.node)!"">
188 {"type": "display-entity", "value": ${entityValue?json_string}}
189 </#macro>
190
191 <#macro "drop-down">
192 <#assign options = sri.getFieldOptions(.node)!>
193 <#assign currentValue = sri.getFieldValueString(.node)!"">
194 {"type": "drop-down", "value": ${(options.get(currentValue)!currentValue)?json_string}}
195 </#macro>
196
197 <#macro "text-area">
198 <#assign fieldValue = sri.getFieldValueString(.node)!"">
199 {"type": "text-area", "value": ${fieldValue?json_string}}
200 </#macro>
201
202 <#macro "text-line">
203 <#assign fieldValue = sri.getFieldValueString(.node)!"">
204 {"type": "text-line", "value": ${fieldValue?json_string}}
205 </#macro>
206
207 <#macro "text-find">
208 <#assign fieldValue = sri.getFieldValueString(.node)!"">
209 {"type": "text-find", "value": ${fieldValue?json_string}}
210 </#macro>
211
212 <#macro "submit">
213 <#assign text = ec.resource.expand(.node["@text"], "")!"">
214 {"type": "submit", "text": ${text?json_string}}
215 </#macro>
216
217 <#macro "password"></#macro>
218 <#macro "hidden"></#macro>
...@@ -75,6 +75,11 @@ ...@@ -75,6 +75,11 @@
75 <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> 75 <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if>
76 76
77 [${linkText}](${dotPath})<#t> 77 [${linkText}](${dotPath})<#t>
78 <#if mcpSemanticData??>
79 <#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if>
80 <#assign linkInfo = {"text": linkText, "path": dotPath, "type": "navigation"}>
81 <#assign dummy = mcpSemanticData.links.add(linkInfo)>
82 </#if>
78 </#if> 83 </#if>
79 </#macro> 84 </#macro>
80 85
...@@ -94,6 +99,19 @@ ...@@ -94,6 +99,19 @@
94 <#macro "form-single"> 99 <#macro "form-single">
95 <#assign formNode = sri.getFormNode(.node["@name"])> 100 <#assign formNode = sri.getFormNode(.node["@name"])>
96 <#assign mapName = formNode["@map"]!"fieldValues"> 101 <#assign mapName = formNode["@map"]!"fieldValues">
102 <#assign formMap = ec.resource.expression(mapName, "")!>
103
104 <#if mcpSemanticData??>
105 <#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})</#if>
106
107 <#assign formMeta = {}>
108 <#assign formMeta = formMeta + {"name": .node["@name"]!"", "map": mapName}>
109 <#assign fieldMetaList = []>
110
111 <#assign dummy = mcpSemanticData.formMeta.put(.node["@name"], formMeta)>
112 </#if>
113
114 <#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if>
97 <#t>${sri.pushSingleFormMapContext(mapName)} 115 <#t>${sri.pushSingleFormMapContext(mapName)}
98 <#list formNode["field"] as fieldNode> 116 <#list formNode["field"] as fieldNode>
99 <#assign fieldSubNode = ""> 117 <#assign fieldSubNode = "">
...@@ -101,9 +119,28 @@ ...@@ -101,9 +119,28 @@
101 <#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if> 119 <#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if>
102 <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true"> 120 <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true">
103 <#assign title><@fieldTitle fieldSubNode/></#assign> 121 <#assign title><@fieldTitle fieldSubNode/></#assign>
122
123 <#if mcpSemanticData??>
124 <#assign fieldMeta = {}>
125 <#assign fieldMeta = fieldMeta + {"name": fieldNode["@name"]!"", "title": title!"", "required": (fieldNode["@required"]! == "true")}>
126
127 <#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if>
128 <#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if>
129 <#if fieldSubNode["drop-down"]?has_content><#assign dummy = fieldMeta.put("type", "dropdown")></#if>
130 <#if fieldSubNode["check"]?has_content><#assign dummy = fieldMeta.put("type", "checkbox")></#if>
131 <#if fieldSubNode["date-find"]?has_content><#assign dummy = fieldMeta.put("type", "date")></#if>
132
133 <#assign dummy = fieldMetaList.add(fieldMeta)>
134 </#if>
135
104 * **${title}**: <#recurse fieldSubNode> 136 * **${title}**: <#recurse fieldSubNode>
105 </#if> 137 </#if>
106 </#list> 138 </#list>
139
140 <#if mcpSemanticData?? && fieldMetaList?has_content>
141 <#assign dummy = mcpSemanticData.formMeta[.node["@name"]!].put("fields", fieldMetaList)>
142 </#if>
143
107 <#t>${sri.popContext()} 144 <#t>${sri.popContext()}
108 </#macro> 145 </#macro>
109 146
...@@ -113,6 +150,31 @@ ...@@ -113,6 +150,31 @@
113 <#assign formNode = formListInfo.getFormNode()> 150 <#assign formNode = formListInfo.getFormNode()>
114 <#assign formListColumnList = formListInfo.getAllColInfo()> 151 <#assign formListColumnList = formListInfo.getAllColInfo()>
115 <#assign listObject = formListInfo.getListObject(false)!> 152 <#assign listObject = formListInfo.getListObject(false)!>
153 <#assign totalItems = listObject?size>
154
155 <#if mcpSemanticData?? && listObject?has_content>
156 <#assign truncatedList = listObject>
157 <#if listObject?size > 50>
158 <#assign truncatedList = listObject?take(50)>
159 </#if>
160 <#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)>
161
162 <#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})</#if>
163
164 <#assign columnNames = []>
165 <#list formListColumnList as columnFieldList>
166 <#assign fieldNode = columnFieldList[0]>
167 <#assign dummy = columnNames.add(fieldNode["@name"]!"")>
168 </#list>
169
170 <#assign dummy = mcpSemanticData.listMeta.put(.node["@name"]!"", {
171 "name": .node["@name"]!"",
172 "totalItems": totalItems,
173 "displayedItems": truncatedList?size,
174 "truncated": (listObject?size > 50),
175 "columns": columnNames
176 })>
177 </#if>
116 178
117 <#-- Header Row --> 179 <#-- Header Row -->
118 <#list formListColumnList as columnFieldList> 180 <#list formListColumnList as columnFieldList>
...@@ -123,7 +185,7 @@ ...@@ -123,7 +185,7 @@
123 | 185 |
124 <#list formListColumnList as columnFieldList>| --- </#list>| 186 <#list formListColumnList as columnFieldList>| --- </#list>|
125 <#-- Data Rows --> 187 <#-- Data Rows -->
126 <#list listObject as listEntry> 188 <#list (truncatedList?? && truncatedList?size > 0)!listObject as listEntry>
127 <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)} 189 <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)}
128 <#list formListColumnList as columnFieldList> 190 <#list formListColumnList as columnFieldList>
129 <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t> 191 <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t>
......
...@@ -174,16 +174,20 @@ ...@@ -174,16 +174,20 @@
174 174
175 try { 175 try {
176 // Consolidated Tool Dispatching 176 // Consolidated Tool Dispatching
177 177
178 ec.logger.info("MCP ToolsCall: Dispatching tool name=${name}, arguments=${arguments}")
179 ec.logger.info("MCP ToolsCall: CODE VERSION: 2025-01-09 - FIXED NULL CHECK")
180
178 if (name == "moqui_render_screen") { 181 if (name == "moqui_render_screen") {
179 def screenPath = arguments?.path 182 def screenPath = arguments?.path
180 def parameters = arguments?.parameters ?: [:] 183 def parameters = arguments?.parameters ?: [:]
181 def renderMode = arguments?.renderMode ?: "mcp" 184 def renderMode = arguments?.renderMode ?: "mcp"
182 def subscreenName = arguments?.subscreenName 185 def subscreenName = arguments?.subscreenName
186 def terse = arguments?.terse ?: false
183 187
184 if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter") 188 if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter")
185 189
186 ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}") 190 ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}, terse=${terse}")
187 191
188 // Strip query parameters from path if present 192 // Strip query parameters from path if present
189 if (screenPath.contains("?")) { 193 if (screenPath.contains("?")) {
...@@ -193,8 +197,10 @@ ...@@ -193,8 +197,10 @@
193 // Handle component:// or simple dot notation path 197 // Handle component:// or simple dot notation path
194 def resolvedPath = screenPath 198 def resolvedPath = screenPath
195 def resolvedSubscreen = subscreenName 199 def resolvedSubscreen = subscreenName
196 200
197 if (!resolvedPath.startsWith("component://")) { 201 ec.logger.info("MCP ToolsCall: Starting path resolution, screenPath=${screenPath}, resolvedPath=${resolvedPath}")
202
203 if (resolvedPath && !resolvedPath.startsWith("component://")) {
198 // Simple dot notation or path conversion 204 // Simple dot notation or path conversion
199 // Longest prefix match for XML screen files 205 // Longest prefix match for XML screen files
200 def pathParts = resolvedPath.split('\\.') 206 def pathParts = resolvedPath.split('\\.')
...@@ -229,10 +235,11 @@ ...@@ -229,10 +235,11 @@
229 } 235 }
230 236
231 def screenCallParams = [ 237 def screenCallParams = [
232 screenPath: resolvedPath, 238 path: resolvedPath,
233 parameters: parameters, 239 parameters: parameters,
234 renderMode: renderMode, 240 renderMode: renderMode,
235 sessionId: sessionId 241 sessionId: sessionId,
242 terse: terse
236 ] 243 ]
237 if (resolvedSubscreen) screenCallParams.subscreenName = resolvedSubscreen 244 if (resolvedSubscreen) screenCallParams.subscreenName = resolvedSubscreen
238 245
...@@ -706,12 +713,13 @@ ...@@ -706,12 +713,13 @@
706 <service verb="execute" noun="ScreenAsMcpTool" authenticate="true" allow-remote="true" transaction-timeout="120"> 713 <service verb="execute" noun="ScreenAsMcpTool" authenticate="true" allow-remote="true" transaction-timeout="120">
707 <description>Execute a screen as an MCP tool</description> 714 <description>Execute a screen as an MCP tool</description>
708 <in-parameters> 715 <in-parameters>
709 <parameter name="screenPath" required="true"/> 716 <parameter name="path" required="true"/>
710 <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter> 717 <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter>
711 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> 718 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter>
712 <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> 719 <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter>
713 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> 720 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter>
714 <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter> 721 <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter>
722 <parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter>
715 </in-parameters> 723 </in-parameters>
716 <out-parameters> 724 <out-parameters>
717 <parameter name="result" type="Map"/> 725 <parameter name="result" type="Map"/>
...@@ -778,6 +786,94 @@ def getWikiInstructions = { screenPath -> ...@@ -778,6 +786,94 @@ def getWikiInstructions = { screenPath ->
778 return null 786 return null
779 } 787 }
780 788
789 // Optimized recursive serializer for Moqui/Java objects to JSON-friendly Map/List
790 // When terse=true, returns minimal data with truncation metadata for easy access to full version
791 def serializeMoquiObject
792 serializeMoquiObject = { obj, depth = 0, isTerse = false ->
793 if (depth > 5) return "..." // Prevent deep recursion
794
795 if (obj == null) return null
796 if (obj instanceof Map) {
797 def newMap = [:]
798 obj.each { k, v ->
799 def keyStr = k.toString()
800 // Skip internal framework keys and metadata fields
801 if (keyStr.startsWith("_") || keyStr == "ec" || keyStr == "sri") return
802 // Skip audit fields to reduce payload
803 if (keyStr in ["lastUpdatedStamp", "lastUpdatedTxStamp", "createdDate", "createdTxStamp", "createdByUserLogin"]) return
804 def value = serializeMoquiObject(v, depth + 1, isTerse)
805 if (value != null) newMap[keyStr] = value
806 }
807 return newMap
808 }
809 if (obj instanceof Iterable) {
810 def list = obj.collect()
811 // Apply truncation only if terse mode is enabled
812 if (isTerse && list.size() > 10) {
813 ec.logger.info("serializeMoquiObject: Terse mode - truncating list from ${list.size()} to 10 items")
814 def truncated = list.take(10)
815 def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
816 return [
817 _items: resultList,
818 _totalCount: list.size(),
819 _truncated: true,
820 _hasMore: true,
821 _message: "Terse mode: showing first 10 of ${list.size()} items. Set terse=false to get full data."
822 ]
823 }
824 // Full data in non-terse mode (but still apply depth protection)
825 def maxItems = isTerse ? 10 : 50
826 def truncated = list.take(maxItems)
827 def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
828 if (!isTerse && list.size() > 50) {
829 ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to 50 items for size limits")
830 return [
831 _items: resultList,
832 _totalCount: list.size(),
833 _truncated: true,
834 _hasMore: true,
835 _message: "Non-terse mode: showing first 50 of ${list.size()} items (size limit reached)"
836 ]
837 }
838 return resultList
839 }
840 if (obj instanceof org.moqui.entity.EntityValue) {
841 return serializeMoquiObject(obj.getMap(), depth + 1, isTerse)
842 }
843 if (obj instanceof java.sql.Timestamp || obj instanceof java.util.Date) {
844 return obj.toString()
845 }
846 if (obj instanceof Number || obj instanceof Boolean) {
847 return obj
848 }
849 if (obj instanceof String) {
850 // Apply truncation only if terse mode is enabled
851 if (isTerse && obj.length() > 200) {
852 return [
853 _value: obj.substring(0, 200) + "...",
854 _fullLength: obj.length(),
855 _truncated: true,
856 _message: "Terse mode: truncated to 200 chars. Set terse=false to get full data."
857 ]
858 }
859 // Full data in non-terse mode (no string truncation)
860 return obj
861 }
862 if (obj.getClass().getName().startsWith("org.moqui.impl.screen.ScreenDefinition")) {
863 return [location: obj.location]
864 }
865 // Fallback for unknown types - truncate if too long
866 def str = obj.toString()
867 if (str.length() > 200) {
868 return [
869 _value: str.substring(0, 200) + "...",
870 _fullLength: str.length(),
871 _truncated: true
872 ]
873 }
874 return str
875 }
876
781 // Resolve input screen path to simple path for lookup 877 // Resolve input screen path to simple path for lookup
782 def inputScreenPath = screenPath 878 def inputScreenPath = screenPath
783 if (screenPath.startsWith("component://")) { 879 if (screenPath.startsWith("component://")) {
...@@ -788,16 +884,13 @@ ec.logger.info("MCP Screen Execution: Looking up wiki docs for ${inputScreenPath ...@@ -788,16 +884,13 @@ ec.logger.info("MCP Screen Execution: Looking up wiki docs for ${inputScreenPath
788 // Try to get wiki instructions 884 // Try to get wiki instructions
789 def wikiInstructions = getWikiInstructions(inputScreenPath) 885 def wikiInstructions = getWikiInstructions(inputScreenPath)
790 886
791 // Check if action is being processed - use real screen rendering if so
792 def isActionExecution = parameters?.action != null
793
794 // Try to render screen content for LLM consumption 887 // Try to render screen content for LLM consumption
795 def output = null 888 def output = null
796 def screenUrl = "http://localhost:8080/${screenPath}" 889 def screenUrl = "http://localhost:8080/${screenPath}"
797 def isError = false 890 def isError = false
798 891
799 try { 892 try {
800 ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen, action=${parameters?.action}") 893 ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen")
801 894
802 def testScreenPath = screenPath 895 def testScreenPath = screenPath
803 def rootScreen = "component://webroot/screen/webroot.xml" 896 def rootScreen = "component://webroot/screen/webroot.xml"
...@@ -806,10 +899,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -806,10 +899,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
806 def isStandalone = false 899 def isStandalone = false
807 900
808 if (screenPath.startsWith("component://")) { 901 if (screenPath.startsWith("component://")) {
809 def pathAfterComponent = screenPath.substring(12).replace('.xml','') // Remove "component://" 902 def pathAfterComponent = screenPath.substring(12).replace('.xml','')
810 def pathParts = pathAfterComponent.split("/")
811 903
812 // Check if target screen itself is standalone
813 try { 904 try {
814 targetScreenDef = ec.screen.getScreenDefinition(screenPath) 905 targetScreenDef = ec.screen.getScreenDefinition(screenPath)
815 if (targetScreenDef?.screenNode) { 906 if (targetScreenDef?.screenNode) {
...@@ -826,13 +917,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -826,13 +917,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
826 } 917 }
827 918
828 if (!isStandalone) { 919 if (!isStandalone) {
829 // Check if screen path itself is a valid screen definition
830 try { 920 try {
831 if (ec.screen.getScreenDefinition(screenPath)) { 921 if (ec.screen.getScreenDefinition(screenPath)) {
832 rootScreen = screenPath 922 rootScreen = screenPath
833 testScreenPath = "" 923 testScreenPath = ""
834 } else { 924 } else {
835 // Original component root logic
836 if (pathAfterComponent.startsWith("webroot/screen/")) { 925 if (pathAfterComponent.startsWith("webroot/screen/")) {
837 rootScreen = "component://webroot/screen/webroot.xml" 926 rootScreen = "component://webroot/screen/webroot.xml"
838 testScreenPath = pathAfterComponent.substring("webroot/screen/".length()) 927 testScreenPath = pathAfterComponent.substring("webroot/screen/".length())
...@@ -852,10 +941,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -852,10 +941,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
852 testScreenPath = "" 941 testScreenPath = ""
853 } 942 }
854 943
855 // Get final screen definition for MCP data extraction 944 // Get final screen definition for data extraction
856 def finalScreenDef = rootScreen ? ec.screen.getScreenDefinition(rootScreen) : null 945 def finalScreenDef = rootScreen ? ec.screen.getScreenDefinition(rootScreen) : null
857 if (finalScreenDef && testScreenPath) { 946 if (finalScreenDef && testScreenPath) {
858 // Navigate to subscreen
859 def pathSegments = testScreenPath.split('/') 947 def pathSegments = testScreenPath.split('/')
860 for (segment in pathSegments) { 948 for (segment in pathSegments) {
861 if (finalScreenDef) { 949 if (finalScreenDef) {
...@@ -868,134 +956,124 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -868,134 +956,124 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
868 } 956 }
869 } 957 }
870 } 958 }
959
960 // Regular screen rendering with current user context - use our custom ScreenTestImpl
961 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
962 .rootScreen(rootScreen)
963 .renderMode(renderMode ? renderMode : "mcp")
964 .auth(ec.user.username)
871 965
872 // Extract MCP-specific data when renderMode is "mcp" or "json" 966 def renderParams = parameters ?: [:]
873 def mcpData = [:] 967 renderParams.userId = ec.user.userId
874 if ((renderMode == "mcp" || renderMode == "json") && finalScreenDef) { 968 renderParams.username = ec.user.username
875 ec.logger.info("MCP Screen Execution: Extracting MCP data for ${screenPath}") 969
876 970 def relativePath = subscreenName ? subscreenName.replaceAll('_','/') : testScreenPath
877 // Extract parameters 971 ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}")
878 if (finalScreenDef.parameterByName) { 972
879 mcpData.parameters = [:] 973 def testRender = screenTest.render(relativePath, renderParams, "POST")
880 finalScreenDef.parameterByName.each { name, param -> 974 output = testRender.getOutput()
881 def value = ec.context.get(name) ?: parameters?.get(name) 975
882 mcpData.parameters[name] = [name: name, value: value, type: "parameter"] 976 // --- NEW: Semantic State Extraction ---
977 def postContext = testRender.getPostRenderContext()
978 def semanticState = [:]
979 def isTerse = context.terse == true
980
981 if (finalScreenDef && postContext) {
982 semanticState.screenPath = inputScreenPath
983 semanticState.terse = isTerse
984 semanticState.data = [:]
985
986 // Use the explicit semantic data captured by macros if available
987 def explicitData = postContext.get("mcpSemanticData")
988 if (explicitData instanceof Map) {
989 explicitData.each { k, v ->
990 semanticState.data[k] = serializeMoquiObject(v, 0, isTerse)
883 } 991 }
884 } 992 }
885 993
886 // Extract forms and their fields 994 // Extract transitions (Actions) with metadata (from screen definition, not macros)
887 if (finalScreenDef.formByName) { 995 semanticState.actions = []
888 mcpData.forms = [] 996 finalScreenDef.getAllTransitions().each { trans ->
889 finalScreenDef.formByName.each { formName, form -> 997 def actionInfo = [
890 def formInfo = [name: formName, fields: []] 998 name: trans.getName(),
891 def formNode = form.internalFormNode 999 service: trans.getSingleServiceName()
892 if (formNode) { 1000 ]
893 // Extract field elements 1001 semanticState.actions << actionInfo
894 def fields = formNode.children('field')
895 fields.each { field ->
896 def fieldName = field.attribute('name')
897 if (fieldName && field) {
898 def fieldInfo = [name: fieldName]
899 if (field.getName()) fieldInfo.type = field.getName()
900 def value = ec.context.get(fieldName) ?: parameters?.get(fieldName)
901 if (value) fieldInfo.value = value
902
903 // Check if it's a widget with options
904 if (field.'drop-down' || field.'check' || field.'radio') {
905 fieldInfo.widgetType = "selection"
906 }
907 formInfo.fields << fieldInfo
908 }
909 }
910 }
911 if (formInfo.fields) mcpData.forms << formInfo
912 }
913 } 1002 }
914 1003
915 // Extract transitions (actions like "Update" buttons) 1004 // 3. Extract parameters that are currently set
916 ec.logger.info("MCP Data Extraction: renderMode=${renderMode}, hasScreenDef=${finalScreenDef != null}, hasTransition=${finalScreenDef?.hasTransition(null)}") 1005 semanticState.parameters = [:]
917 if (finalScreenDef.hasTransition(null)) { 1006 if (finalScreenDef.parameterByName) {
918 mcpData.actions = [] 1007 finalScreenDef.parameterByName.each { name, param ->
919 finalScreenDef.getTransitionList().each { trans -> 1008 def value = postContext.get(name) ?: parameters?.get(name)
920 mcpData.actions << [ 1009 if (value != null) semanticState.parameters[name] = serializeMoquiObject(value, 0, isTerse)
921 name: trans.name,
922 service: trans.xmlTransition ? trans.xmlTransition.attribute('service') : null,
923 description: trans.description
924 ]
925 } 1010 }
926 } 1011 }
1012
1013 // Log semantic state size for optimization tracking
1014 def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString()
1015 def semanticStateSize = semanticStateJson.length()
1016 ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}")
927 } 1017 }
928 1018
929 // Regular screen rendering with current user context - use our custom ScreenTestImpl 1019 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
930 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
931 .rootScreen(rootScreen)
932 .renderMode(renderMode ? renderMode : "mcp")
933 .auth(ec.user.username)
934
935 def renderParams = parameters ?: [:]
936 renderParams.userId = ec.user.userId
937 renderParams.username = ec.user.username
938
939 def relativePath = subscreenName ? subscreenName.replaceAll('_','/') : testScreenPath
940 ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}")
941
942 def testRender = screenTest.render(relativePath, renderParams, "POST")
943 output = testRender.getOutput()
944 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
945 1020
946 } catch (Exception e) { 1021 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
947 isError = true 1022
948 ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
949 output = "SCREEN RENDERING ERROR: ${e.message}\n\nTroubleshooting Suggestions:\n1. Check if the screen path is correct\n2. Verify user permissions\n3. Check server logs"
950 }
951
952 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
953
954 // Build result based on renderMode 1023 // Build result based on renderMode
955 def content = [] 1024 def content = []
956 if ((renderMode == "mcp" || renderMode == "json") && mcpData) { 1025 if ((renderMode == "mcp" || renderMode == "json") && semanticState) {
957 // Return structured MCP data 1026 // Return structured MCP data
958 def mcpResult = [ 1027 def mcpResult = [
959 screenPath: screenPath, 1028 screenPath: screenPath,
960 screenUrl: screenUrl, 1029 screenUrl: screenUrl,
961 executionTime: executionTime, 1030 executionTime: executionTime,
962 isError: isError 1031 isError: isError,
963 ] 1032 semanticState: semanticState
964 if (mcpData.parameters) mcpResult.parameters = mcpData.parameters 1033 ]
965 if (mcpData.forms) mcpResult.forms = mcpData.forms 1034
966 if (mcpData.actions) mcpResult.actions = mcpData.actions 1035 // Truncate text preview to 500 chars to save tokens, since we have structured data
967 if (output) mcpResult.htmlPreview = output.take(2000) + (output.length() > 2000 ? "..." : "") 1036 if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "")
968 if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions 1037 if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions
969 1038
970 content << [ 1039 content << [
971 type: "text", 1040 type: "text",
972 text: new groovy.json.JsonBuilder(mcpResult).toString() 1041 text: new groovy.json.JsonBuilder(mcpResult).toString()
973 ] 1042 ]
974 } else { 1043 } else {
975 // Return raw output for other modes 1044 // Return raw output for other modes (text, html, etc)
976 def textOutput = output 1045 def textOutput = output
977 if (wikiInstructions) { 1046 if (wikiInstructions) {
978 textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}" 1047 textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}"
1048 }
1049 content << [
1050 type: "text",
1051 text: textOutput,
1052 screenPath: screenPath,
1053 screenUrl: screenUrl,
1054 executionTime: executionTime,
1055 isError: isError
1056 ]
1057 }
1058
1059 result = [
1060 content: content,
1061 isError: false
1062 ]
1063 return // Success!
1064
1065 } catch (Exception e) {
1066 isError = true
1067 ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
1068 output = "SCREEN RENDERING ERROR: ${e.message}"
1069 result = [
1070 isError: true,
1071 content: [[type: "text", text: output]]
1072 ]
979 } 1073 }
980 content << [ 1074 ]]></script>
981 type: "text", 1075 </actions>
982 text: textOutput, 1076 </service>
983 screenPath: screenPath,
984 screenUrl: screenUrl,
985 executionTime: executionTime,
986 isError: isError
987 ]
988 }
989
990 result = [
991 content: content,
992 isError: false
993 ]
994
995 ec.logger.info("MCP Screen Execution: Returned result for screen ${screenPath} in ${executionTime}s")
996 ]]></script>
997 </actions>
998 </service>
999 1077
1000 <service verb="mcp" noun="ResourcesTemplatesList" authenticate="false" allow-remote="true" transaction-timeout="30"> 1078 <service verb="mcp" noun="ResourcesTemplatesList" authenticate="false" allow-remote="true" transaction-timeout="30">
1001 <description>Handle MCP resources/templates/list request</description> 1079 <description>Handle MCP resources/templates/list request</description>
...@@ -1274,6 +1352,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1274,6 +1352,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1274 <parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter> 1352 <parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter>
1275 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter> 1353 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter>
1276 <parameter name="sessionId"/> 1354 <parameter name="sessionId"/>
1355 <parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter>
1277 </in-parameters> 1356 </in-parameters>
1278 <out-parameters> 1357 <out-parameters>
1279 <parameter name="result" type="Map"/> 1358 <parameter name="result" type="Map"/>
...@@ -1667,7 +1746,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1667,7 +1746,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1667 screenPath: screenPath, 1746 screenPath: screenPath,
1668 parameters: renderParams, 1747 parameters: renderParams,
1669 renderMode: actualRenderMode, 1748 renderMode: actualRenderMode,
1670 sessionId: sessionId 1749 sessionId: sessionId,
1750 terse: context.terse == true
1671 ] 1751 ]
1672 if (subscreenName) screenCallParams.subscreenName = subscreenName 1752 if (subscreenName) screenCallParams.subscreenName = subscreenName
1673 1753
...@@ -1676,18 +1756,51 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1676,18 +1756,51 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1676 .parameters(screenCallParams) 1756 .parameters(screenCallParams)
1677 .call() 1757 .call()
1678 1758
1679 // Extract rendered content from result 1759 // Extract rendered content and semantic state from result
1680 // ScreenAsMcpTool returns {content: [{type: "text", text: "...", ...}]}
1681 ec.logger.info("BrowseScreens: serviceResult keys: ${serviceResult?.keySet()}, has content: ${serviceResult?.containsKey('content')}, has result: ${serviceResult?.containsKey('result')}")
1682 if (serviceResult) { 1760 if (serviceResult) {
1761 def resultObj = null
1683 if (serviceResult.containsKey('content') && serviceResult.content && serviceResult.content.size() > 0) { 1762 if (serviceResult.containsKey('content') && serviceResult.content && serviceResult.content.size() > 0) {
1684 renderedContent = serviceResult.content[0].text 1763 def rawText = serviceResult.content[0].text
1685 ec.logger.info("BrowseScreens: Extracted content from serviceResult.content[0].text") 1764 if (rawText && rawText.startsWith("{")) {
1765 try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {}
1766 }
1767 renderedContent = rawText
1686 } else if (serviceResult.containsKey('result') && serviceResult.result && serviceResult.result.content && serviceResult.result.content.size() > 0) { 1768 } else if (serviceResult.containsKey('result') && serviceResult.result && serviceResult.result.content && serviceResult.result.content.size() > 0) {
1687 renderedContent = serviceResult.result.content[0].text 1769 def rawText = serviceResult.result.content[0].text
1688 ec.logger.info("BrowseScreens: Extracted content from serviceResult.result.content[0].text") 1770 if (rawText && rawText.startsWith("{")) {
1689 } else { 1771 try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {}
1690 ec.logger.info("BrowseScreens: serviceResult structure: ${serviceResult}, result content size: ${serviceResult?.result?.content?.size()}") 1772 }
1773 renderedContent = rawText
1774 }
1775
1776 if (resultObj && resultObj.semanticState) {
1777 resultMap.semanticState = resultObj.semanticState
1778
1779 // Build UI narrative for LLM guidance
1780 try {
1781 def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder()
1782 // Get screen definition for narrative building
1783 def screenDefForNarrative = null
1784 if (screenPath) {
1785 screenDefForNarrative = ec.screen.getScreenDefinition(screenPath)
1786 }
1787
1788 def uiNarrative = narrativeBuilder.buildNarrative(
1789 screenDefForNarrative,
1790 resultObj.semanticState,
1791 currentPath,
1792 context.terse == true
1793 )
1794 resultMap.uiNarrative = uiNarrative
1795 ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}")
1796 } catch (Exception e) {
1797 ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}")
1798 }
1799
1800 // If we have semantic state, we can truncate the rendered content to save tokens
1801 if (renderedContent && renderedContent.length() > 500) {
1802 renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)"
1803 }
1691 } 1804 }
1692 } 1805 }
1693 1806
...@@ -1947,7 +2060,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1947,7 +2060,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1947 properties: [ 2060 properties: [
1948 "path": [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], 2061 "path": [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"],
1949 "parameters": [type: "object", description: "Parameters for the screen"], 2062 "parameters": [type: "object", description: "Parameters for the screen"],
1950 "renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"] 2063 "renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"],
2064 "terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"]
1951 ], 2065 ],
1952 required: ["path"] 2066 required: ["path"]
1953 ] 2067 ]
...@@ -1962,7 +2076,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1962,7 +2076,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1962 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], 2076 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"],
1963 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], 2077 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"],
1964 "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"], 2078 "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"],
1965 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"] 2079 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"],
2080 "terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"]
1966 ] 2081 ]
1967 ] 2082 ]
1968 ], 2083 ],
......
...@@ -291,6 +291,11 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -291,6 +291,11 @@ class CustomScreenTestImpl implements McpScreenTest {
291 // push the context 291 // push the context
292 ContextStack cs = eci.getContext() 292 ContextStack cs = eci.getContext()
293 cs.push() 293 cs.push()
294
295 // Create a persistent map for semantic data that survives nested pops
296 Map<String, Object> mcpSemanticData = new HashMap<>()
297 cs.put("mcpSemanticData", mcpSemanticData)
298
294 // create the WebFacadeStub using our custom method 299 // create the WebFacadeStub using our custom method
295 org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath) 300 org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath)
296 // set stub on eci, will also put parameters in the context 301 // set stub on eci, will also put parameters in the context
...@@ -336,8 +341,10 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -336,8 +341,10 @@ class CustomScreenTestImpl implements McpScreenTest {
336 // calc renderTime 341 // calc renderTime
337 stri.renderTime = System.currentTimeMillis() - startTime 342 stri.renderTime = System.currentTimeMillis() - startTime
338 343
344 // capture everything currently in the context stack before popping
345 stri.postRenderContext = new HashMap<>(cs)
339 // pop the context stack, get rid of var space 346 // pop the context stack, get rid of var space
340 stri.postRenderContext = cs.pop() 347 cs.pop()
341 348
342 // check, pass through, error messages 349 // check, pass through, error messages
343 if (eci.message.hasError()) { 350 if (eci.message.hasError()) {
......
1 /*
2 * This software is in public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 *
5 * To the extent possible under law, author(s) have dedicated all
6 * copyright and related and neighboring rights to this software to the
7 * public domain worldwide. This software is distributed without any
8 * warranty.
9 *
10 * You should have received a copy of the CC0 Public Domain Dedication
11 * along with this software (see the LICENSE.md file). If not, see
12 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13 */
14 package org.moqui.mcp
15
16 import org.moqui.impl.screen.ScreenDefinition
17 import org.slf4j.Logger
18 import org.slf4j.LoggerFactory
19
20 /**
21 * Builds UI narrative for MCP screen responses.
22 * Creates structured, story-like descriptions that guide LLM on how to invoke actions.
23 */
24 class UiNarrativeBuilder {
25 protected final static Logger logger = LoggerFactory.getLogger(UiNarrativeBuilder.class)
26
27 private int countForms(Map semanticState) {
28 if (!semanticState?.data) return 0
29 int count = 0
30 semanticState.data.keySet().each { k ->
31 if (k.toString().toLowerCase().contains('form')) {
32 count++
33 }
34 }
35 return count
36 }
37
38 private int countLists(Map semanticState) {
39 if (!semanticState?.data) return 0
40 int count = 0
41 semanticState.data.keySet().each { k ->
42 if (k.toString().toLowerCase().contains('list')) {
43 count++
44 }
45 }
46 return count
47 }
48
49 Map<String, Object> buildNarrative(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) {
50 def narrative = [:]
51
52 narrative.screen = describeScreen(screenDef, semanticState, isTerse)
53 narrative.actions = describeActions(screenDef, semanticState, currentPath, isTerse)
54 narrative.navigation = describeLinks(semanticState, currentPath, isTerse)
55 narrative.notes = describeNotes(semanticState, isTerse)
56
57 return narrative
58 }
59
60 String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState, boolean isTerse) {
61 def screenName = screenDef?.name ?: "Screen"
62 def sb = new StringBuilder()
63
64 sb.append("${screenName} displays ")
65
66 def formCount = countForms(semanticState)
67 def listCount = countLists(semanticState)
68 def itemCount = countItems(semanticState)
69
70 if (listCount > 0 && itemCount > 0) {
71 sb.append("${itemCount} item${itemCount > 1 ? 's' : ''} in ${listCount} list${listCount > 1 ? 's' : ''}")
72 if (formCount > 0) sb.append(" with a search form")
73 } else if (formCount > 0) {
74 sb.append("a form with ${formCount} field${formCount > 1 ? 's' : ''}")
75 } else {
76 sb.append("information")
77 }
78
79 sb.append(". ")
80
81 def forms = semanticState?.data
82 if (forms) {
83 def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }[0..2]
84 if (formNames) {
85 def fields = getFormFieldNames(forms, formNames[0])
86 if (fields) {
87 sb.append("Form contains: ${fields.join(', ')}. ")
88 }
89 }
90 }
91
92 def links = semanticState?.data?.links
93 if (links && links.size() > 0) {
94 def linkTypes = links.collect { l -> l.type?.toString() ?: 'navigation' }.unique()
95 if (linkTypes) {
96 sb.append("Available links: ${linkTypes.take(3).join(', ')}. ")
97 }
98 }
99
100 return sb.toString()
101 }
102
103 List<String> describeActions(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) {
104 def actions = []
105
106 def transitions = semanticState?.actions
107 if (transitions) {
108 transitions.each { trans ->
109 def transName = trans.name?.toString()
110 def service = trans.service?.toString()
111
112 if (transName) {
113 if (service) {
114 actions << buildServiceActionNarrative(transName, service, currentPath, semanticState)
115 } else if (transName.toLowerCase().startsWith('create') || transName.toLowerCase().startsWith('update')) {
116 actions << buildTransitionActionNarrative(transName, currentPath, semanticState)
117 }
118 }
119 }
120 }
121
122 def forms = semanticState?.data
123 if (forms) {
124 def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }
125 formNames.each { formName ->
126 actions << buildFormSubmitNarrative(formName, currentPath, semanticState)
127 }
128 }
129
130 if (actions.isEmpty()) {
131 actions << "No explicit actions available on this screen. Use navigation links to explore."
132 }
133
134 return actions
135 }
136
137 List<String> describeLinks(Map<String, Object> semanticState, String currentPath, boolean isTerse) {
138 def navigation = []
139
140 def links = semanticState?.data?.links
141 if (links && links.size() > 0) {
142 def sortedLinks = links.sort { a, b -> (a.text <=> b.text) }
143
144 sortedLinks.take(5).each { link ->
145 def linkText = link.text?.toString()
146 def linkPath = link.path?.toString()
147 def linkType = link.type?.toString() ?: 'navigation'
148
149 if (linkPath) {
150 if (linkPath.startsWith('#')) {
151 def actionName = linkPath.substring(1)
152 navigation << "To ${linkText.toLowerCase()}, use the '${actionName}' action (see actions section)."
153 } else {
154 navigation << "To ${linkText.toLowerCase()}, call moqui_render_screen(path='${linkPath}')."
155 }
156 }
157 }
158 }
159
160 if (navigation.isEmpty()) {
161 def parentPath = getParentPath(currentPath)
162 if (parentPath) {
163 navigation << "To go back, call moqui_browse_screens(path='${parentPath}')."
164 }
165 }
166
167 return navigation
168 }
169
170 List<String> describeNotes(Map<String, Object> semanticState, boolean isTerse) {
171 def notes = []
172
173 def data = semanticState?.data
174 if (data) {
175 data.each { key, value ->
176 if (value instanceof Map && value.containsKey('_truncated') && value._truncated == true) {
177 def total = value._totalCount ?: 0
178 def shown = value._items?.size() ?: 0
179 notes << "List truncated: showing ${shown} of ${total} item${total > 1 ? 's' : ''}. Set terse=false to view all."
180 }
181 }
182 }
183
184 def actions = semanticState?.actions
185 if (actions && actions.size() > 5) {
186 notes << "This screen has ${actions.size()} actions. Use semanticState.actions for complete list."
187 }
188
189 def parameters = semanticState?.parameters
190 if (parameters && parameters.size() > 0) {
191 def requiredParams = parameters.findAll { k, v -> k.toString().toLowerCase().contains('id') }
192 if (requiredParams.size() > 0) {
193 notes << "Required parameters: ${requiredParams.keySet().join(', ')}."
194 }
195 }
196
197 return notes
198 }
199
200 private String buildServiceActionNarrative(String actionName, String service, String currentPath, Map semanticState) {
201 def actionLower = actionName.toLowerCase()
202 def verb = actionLower.startsWith('create') ? 'create' : actionLower.startsWith('update') ? 'update' : actionLower.startsWith('delete') ? 'delete' : 'execute'
203
204 def params = extractServiceParameters(service, semanticState)
205
206 def sb = new StringBuilder()
207 sb.append("To ${verb} ")
208
209 def object = extractObjectFromAction(actionName)
210 sb.append(object.toLowerCase())
211 sb.append(", call moqui_render_screen(path='${currentPath}', action='${actionName}'")
212
213 if (params) {
214 sb.append(", parameters={${params}}")
215 }
216
217 sb.append("). ")
218 sb.append("This invokes service '${service}' via transition.")
219
220 return sb.toString()
221 }
222
223 private String buildTransitionActionNarrative(String actionName, String currentPath, Map semanticState) {
224 def actionLower = actionName.toLowerCase()
225 def verb = actionLower.startsWith('create') ? 'create' : actionLower.startsWith('update') ? 'update' : actionLower.startsWith('delete') ? 'delete' : 'process'
226
227 def params = extractTransitionParameters(actionName, semanticState)
228
229 def sb = new StringBuilder()
230 sb.append("To ${verb} ")
231
232 def object = extractObjectFromAction(actionName)
233 sb.append(object.toLowerCase())
234 sb.append(", call moqui_render_screen(path='${currentPath}', action='${actionName}'")
235
236 if (params) {
237 sb.append(", parameters={${params}}")
238 }
239
240 sb.append("). ")
241 sb.append("This triggers the '${actionName}' transition on this screen.")
242
243 return sb.toString()
244 }
245
246 private String buildFormSubmitNarrative(String formName, String currentPath, Map semanticState) {
247 def formFriendly = formFriendlyName(formName)
248 def params = extractFormParameters(formName, semanticState)
249
250 def sb = new StringBuilder()
251 sb.append("To submit ${formFriendly.toLowerCase()}, call moqui_render_screen(path='${currentPath}', parameters={${params}}). ")
252 sb.append("This filters or processes the ${formFriendly.toLowerCase()} form.")
253
254 return sb.toString()
255 }
256
257
258 private int countItems(Map semanticState) {
259 if (!semanticState?.data) return 0
260 def total = 0
261 semanticState.data.each { k, v ->
262 if (v instanceof Map && v.containsKey('_totalCount')) {
263 total += v._totalCount as Integer
264 } else if (v instanceof List) {
265 total += v.size()
266 }
267 }
268 return total
269 }
270
271 private List<String> getFormFieldNames(Map forms, String formName) {
272 def form = forms[formName]
273 if (!form) return []
274
275 if (form instanceof Map) {
276 def result = []
277 form.keySet().each { k ->
278 if (!k.toString().startsWith('_') && result.size() < 5) {
279 result.add(k.toString())
280 }
281 }
282 return result
283 }
284
285 return []
286 }
287
288 private String extractServiceParameters(String service, Map semanticState) {
289 def params = []
290 def allParams = semanticState?.parameters
291
292 if (allParams) {
293 def paramKeys = []
294 allParams.keySet().each { k ->
295 if (paramKeys.size() < 3) {
296 paramKeys.add(k.toString())
297 }
298 }
299 paramKeys.each { key ->
300 def value = allParams[key]
301 if (value != null) {
302 def valStr = value instanceof String ? "'${value}'" : value.toString()
303 params << "${key}: ${valStr}"
304 }
305 }
306 }
307
308 return params.join(', ')
309 }
310
311 private String extractTransitionParameters(String actionName, Map semanticState) {
312 def params = []
313 def allParams = semanticState?.parameters
314
315 if (allParams) {
316 def paramKeys = allParams.keySet().take(3)
317 paramKeys.each { key ->
318 def value = allParams[key]
319 if (value != null) {
320 def valStr = value instanceof String ? "'${value}'" : value.toString()
321 params << "${key}: ${valStr}"
322 }
323 }
324 }
325
326 return params.join(', ')
327 }
328
329 private String extractFormParameters(String formName, Map semanticState) {
330 def form = semanticState?.data?.get(formName)
331 if (!form) return '...'
332
333 def params = []
334 if (form instanceof Map) {
335 def fieldNames = []
336 form.keySet().each { k ->
337 if (!k.toString().startsWith('_') && fieldNames.size() < 3) {
338 fieldNames.add(k.toString())
339 }
340 }
341 fieldNames.each { key ->
342 def value = form[key]
343 if (value != null) {
344 def valStr = value instanceof String ? "'${value}'" : value.toString()
345 params << "${key}: ${valStr}"
346 }
347 }
348 }
349
350 if (params.isEmpty()) params << '...'
351
352 return params.join(', ')
353 }
354
355 private String extractObjectFromAction(String actionName) {
356 def actionLower = actionName.toLowerCase()
357
358 def patterns = [
359 /create(.+)/,
360 /update(.+)/,
361 /delete(.+)/,
362 /find(.+)/,
363 /search(.+)/
364 ]
365
366 for (pattern in patterns) {
367 def m = actionLower =~ pattern
368 if (m.find()) {
369 def object = m.group(1)
370 if (object) {
371 def words = object.split('(?=[A-Z])')
372 def cleaned = words.findAll { w -> w.length() > 0 }.join(' ')
373 return cleaned ?: 'item'
374 }
375 }
376 }
377
378 return 'item'
379 }
380
381 private String formFriendlyName(String formName) {
382 def name = formName.replace('Form', '').replace('form', '')
383 def words = name.split('(?=[A-Z])')
384 return words.findAll { w -> w.length() > 0 }.join(' ') ?: 'Form'
385 }
386
387 private String getParentPath(String path) {
388 if (!path || path == 'root') return null
389
390 def parts = path.split('\\.')
391 if (parts.length > 1) {
392 return parts[0..-2].join('.')
393 }
394
395 return 'root'
396 }
397 }