29dfd245 by Ean Schuessler

Add compact/ARIA render modes, fix framework transitions

- Add compact render mode (now default): LLM-optimized ~75% smaller output
  - Shows forms with fields, grids with sample rows, actions with services
  - Truncates dropdown options with fetch hints

- Add ARIA render mode: W3C accessibility tree format
  - Maps Moqui fields to ARIA roles (textbox, combobox, checkbox, etc.)

- Fix action execution to use framework transitions
  - Actions now append to screen path for proper transition handling
  - Framework handles inheritance, pre/post actions, response handling

- Fix CSRF bypass for MCP requests
  - Set moqui.request.authenticated=true in MockHttpServletRequest attributes
1 parent 03e420e8
...@@ -567,7 +567,7 @@ ...@@ -567,7 +567,7 @@
567 <parameter name="path" required="true"/> 567 <parameter name="path" required="true"/>
568 <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter> 568 <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter>
569 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> 569 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter>
570 <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> 570 <parameter name="renderMode" default="compact"><description>Render mode: compact (default, actionable summary), aria (accessibility tree), mcp (full metadata), text, html, xml, vuet, qvt</description></parameter>
571 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> 571 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter>
572 </in-parameters> 572 </in-parameters>
573 <out-parameters> 573 <out-parameters>
...@@ -731,6 +731,352 @@ serializeMoquiObject = { obj, depth = 0 -> ...@@ -731,6 +731,352 @@ serializeMoquiObject = { obj, depth = 0 ->
731 return str 731 return str
732 } 732 }
733 733
734 // Convert MCP semantic state to ARIA accessibility tree format
735 // This produces a compact, standard representation matching W3C ARIA roles
736 def convertToAriaTree = { semanticState, targetScreenPath ->
737 if (!semanticState) return null
738
739 def data = semanticState.data
740 if (!data) return [role: "document", name: targetScreenPath, children: []]
741
742 def children = []
743 def formMetadata = data.formMetadata ?: [:]
744
745 // Map Moqui field types to ARIA roles
746 def fieldToAriaRole = { field ->
747 switch (field.type) {
748 case "dropdown": return "combobox"
749 case "text": return "textbox"
750 case "textarea": return "textbox"
751 case "checkbox": return "checkbox"
752 case "radio": return "radio"
753 case "date": return "textbox" // with date semantics
754 case "date-time": return "textbox"
755 case "number": return "spinbutton"
756 case "password": return "textbox"
757 case "hidden": return null // skip hidden fields
758 default: return "textbox"
759 }
760 }
761
762 // Convert a field to ARIA node
763 def fieldToAriaNode = { field ->
764 def role = fieldToAriaRole(field)
765 if (!role) return null
766
767 def node = [
768 role: role,
769 name: field.title ?: field.name
770 ]
771
772 // Add required attribute
773 if (field.required) node.required = true
774
775 // For dropdowns, show option count instead of all options
776 if (field.type == "dropdown") {
777 def optionCount = field.options?.size() ?: 0
778 if (field.totalOptions) optionCount = field.totalOptions
779 if (optionCount > 0) {
780 node.options = optionCount
781 if (field.optionsTruncated) {
782 node.fetchHint = field.fetchHint
783 }
784 }
785 // Check for dynamic options
786 if (field.dynamicOptions) {
787 node.autocomplete = true
788 }
789 }
790
791 return node
792 }
793
794 // Process forms (form-single types become form landmarks)
795 formMetadata.each { formName, formData ->
796 if (formData.type == "form-list") return // Handle separately
797
798 def formNode = [
799 role: "form",
800 name: formData.name ?: formName,
801 children: []
802 ]
803
804 // Add fields
805 formData.fields?.each { field ->
806 def fieldNode = fieldToAriaNode(field)
807 if (fieldNode) formNode.children << fieldNode
808 }
809
810 // Find submit button by matching form name pattern
811 // e.g., CreatePersonForm -> createPerson action
812 def formBaseName = formName.replaceAll(/Form$/, "")
813 def expectedActionName = formBaseName.substring(0,1).toLowerCase() + formBaseName.substring(1)
814 def submitAction = semanticState.actions?.find { a ->
815 a.name == expectedActionName || a.name?.equalsIgnoreCase(expectedActionName)
816 }
817 if (submitAction) {
818 formNode.children << [role: "button", name: submitAction.name]
819 }
820
821 if (formNode.children) children << formNode
822 }
823
824 // Process form-lists (become grids)
825 formMetadata.findAll { k, v -> v.type == "form-list" }.each { formName, formData ->
826 def listData = data[formName]
827 def itemCount = 0
828 if (listData instanceof List) {
829 itemCount = listData.size()
830 } else if (listData?._totalCount) {
831 itemCount = listData._totalCount
832 }
833
834 def gridNode = [
835 role: "grid",
836 name: formData.name ?: formName,
837 rowcount: itemCount
838 ]
839
840 // Add column info
841 def columns = formData.fields?.collect { it.title ?: it.name }
842 if (columns) gridNode.columns = columns
843
844 // Add sample rows (first 3)
845 if (listData instanceof List && listData.size() > 0) {
846 gridNode.children = []
847 listData.take(3).each { row ->
848 def rowNode = [role: "row"]
849 // Extract key identifying info
850 def name = row.combinedName ?: row.name ?: row.productName ?: row.pseudoId ?: row.id
851 if (name) rowNode.name = name
852 gridNode.children << rowNode
853 }
854 if (listData.size() > 3) {
855 gridNode.moreRows = listData.size() - 3
856 }
857 }
858
859 children << gridNode
860 }
861
862 // Process navigation links
863 def navLinks = semanticState.data?.links?.findAll { it.type == "navigation" }
864 if (navLinks && navLinks.size() > 0) {
865 def navNode = [
866 role: "navigation",
867 name: "Links",
868 children: navLinks.take(10).collect { link ->
869 [role: "link", name: link.text, path: link.path]
870 }
871 ]
872 if (navLinks.size() > 10) {
873 navNode.moreLinks = navLinks.size() - 10
874 }
875 children << navNode
876 }
877
878 // Process actions as a toolbar
879 def serviceActions = semanticState.actions?.findAll { it.type == "service-action" }
880 if (serviceActions && serviceActions.size() > 0) {
881 def toolbarNode = [
882 role: "toolbar",
883 name: "Actions",
884 children: serviceActions.take(10).collect { action ->
885 [role: "button", name: action.name]
886 }
887 ]
888 if (serviceActions.size() > 10) {
889 toolbarNode.moreActions = serviceActions.size() - 10
890 }
891 children << toolbarNode
892 }
893
894 return [
895 role: "main",
896 name: targetScreenPath?.split('/')?.last() ?: "Screen",
897 children: children
898 ]
899 }
900
901 // Convert MCP semantic state to compact actionable format
902 // Designed for LLM efficiency: enough info to act without fetching more
903 def convertToCompactFormat = { semanticState, targetScreenPath ->
904 if (!semanticState) return null
905
906 def data = semanticState.data
907 def formMetadata = data?.formMetadata ?: [:]
908 def actions = semanticState.actions ?: []
909
910 def result = [
911 screen: targetScreenPath?.split('/')?.last() ?: "Screen"
912 ]
913
914 // Build summary
915 def formCount = formMetadata.count { k, v -> v.type != "form-list" }
916 def listCount = formMetadata.count { k, v -> v.type == "form-list" }
917 def actionNames = actions.findAll { it.type == "service-action" }.collect { it.name }.take(5)
918
919 def summaryParts = []
920 if (formCount > 0) summaryParts << "${formCount} form${formCount > 1 ? 's' : ''}"
921 if (listCount > 0) {
922 // Get total row count
923 def totalRows = 0
924 formMetadata.findAll { k, v -> v.type == "form-list" }.each { formName, formData ->
925 def listData = data[formName]
926 if (listData instanceof List) totalRows += listData.size()
927 }
928 summaryParts << "${totalRows} result${totalRows != 1 ? 's' : ''}"
929 }
930 if (actionNames) summaryParts << "actions: ${actionNames.join(', ')}"
931 result.summary = summaryParts.join(". ")
932
933 // Process forms (non-list)
934 def forms = [:]
935 formMetadata.findAll { k, v -> v.type != "form-list" }.each { formName, formData ->
936 def formInfo = [:]
937 def fields = []
938
939 formData.fields?.each { field ->
940 if (field.type == "hidden") return
941
942 def fieldInfo
943 def fieldName = field.name
944 def displayName = field.title ?: field.name
945
946 if (field.type == "dropdown") {
947 def optionCount = field.totalOptions ?: field.options?.size() ?: 0
948 if (optionCount > 0) {
949 fieldInfo = [(fieldName): [type: "dropdown", options: optionCount]]
950 // Include first few options as examples
951 if (field.options && field.options.size() > 0) {
952 def examples = field.options.take(3).collect { it.value }
953 fieldInfo[fieldName].examples = examples
954 }
955 if (field.dynamicOptions) {
956 fieldInfo[fieldName].autocomplete = true
957 }
958 } else {
959 fieldInfo = [(fieldName): [type: "dropdown"]]
960 }
961 if (displayName != fieldName) fieldInfo[fieldName].label = displayName
962 } else {
963 // Simple field - just use name, add label if different
964 if (displayName != fieldName) {
965 fieldInfo = [(fieldName): displayName]
966 } else {
967 fieldInfo = fieldName
968 }
969 }
970
971 if (field.required) {
972 if (fieldInfo instanceof String) {
973 fieldInfo = [(fieldName): [required: true]]
974 } else if (fieldInfo instanceof Map && fieldInfo[fieldName] instanceof String) {
975 fieldInfo[fieldName] = [label: fieldInfo[fieldName], required: true]
976 } else if (fieldInfo instanceof Map && fieldInfo[fieldName] instanceof Map) {
977 fieldInfo[fieldName].required = true
978 }
979 }
980
981 fields << fieldInfo
982 }
983
984 formInfo.fields = fields
985
986 // Find matching action for this form
987 def formBaseName = formName.replaceAll(/Form$/, "")
988 def expectedActionName = formBaseName.substring(0,1).toLowerCase() + formBaseName.substring(1)
989 def submitAction = actions.find { a ->
990 a.name == expectedActionName || a.name?.equalsIgnoreCase(expectedActionName)
991 }
992 if (submitAction) {
993 formInfo.submit = submitAction.name
994 if (submitAction.service) {
995 formInfo.service = submitAction.service
996 }
997 }
998
999 forms[formName] = formInfo
1000 }
1001 if (forms) result.forms = forms
1002
1003 // Process grids (form-lists)
1004 def grids = [:]
1005 formMetadata.findAll { k, v -> v.type == "form-list" }.each { formName, formData ->
1006 def listData = data[formName]
1007 def gridInfo = [:]
1008
1009 // Column names
1010 def columns = formData.fields?.findAll { it.type != "hidden" }?.collect { it.title ?: it.name }
1011 if (columns) gridInfo.columns = columns
1012
1013 // Rows with key data and links
1014 if (listData instanceof List && listData.size() > 0) {
1015 gridInfo.rowCount = listData.size()
1016 gridInfo.rows = listData.take(10).collect { row ->
1017 def rowInfo = [:]
1018
1019 // Get identifying info
1020 def id = row.pseudoId ?: row.partyId ?: row.productId ?: row.orderId ?: row.id
1021 def name = row.combinedName ?: row.productName ?: row.name
1022
1023 if (id) rowInfo.id = id
1024 if (name && name != id) rowInfo.name = name
1025
1026 // Find link for this row
1027 def rowLinks = data.links?.findAll { link ->
1028 link.path?.contains(id?.toString()) && link.type == "navigation"
1029 }
1030 if (rowLinks && rowLinks.size() > 0) {
1031 // Pick the most relevant link (edit/view)
1032 def editLink = rowLinks.find { it.path?.contains("Edit") }
1033 rowInfo.link = (editLink ?: rowLinks[0]).path
1034 }
1035
1036 rowInfo
1037 }
1038 if (listData.size() > 10) {
1039 gridInfo.more = listData.size() - 10
1040 }
1041 } else {
1042 gridInfo.rowCount = 0
1043 }
1044
1045 grids[formName] = gridInfo
1046 }
1047 if (grids) result.grids = grids
1048
1049 // Actions with parameter hints
1050 def actionMap = [:]
1051 actions.findAll { it.type == "service-action" && it.service }.each { action ->
1052 def actionInfo = [service: action.service]
1053
1054 // Find form that uses this action to get parameter hints
1055 def matchingForm = forms.find { k, v -> v.submit == action.name }
1056 if (matchingForm) {
1057 def requiredFields = matchingForm.value.fields?.findAll { f ->
1058 (f instanceof Map && f.values().any { v ->
1059 (v instanceof Map && v.required) || v == "required"
1060 })
1061 }?.collect { f ->
1062 f instanceof Map ? f.keySet()[0] : f
1063 }
1064 if (requiredFields) actionInfo.required = requiredFields
1065 }
1066
1067 actionMap[action.name] = actionInfo
1068 }
1069 if (actionMap) result.actions = actionMap
1070
1071 // Navigation - only external/important links
1072 def navLinks = data?.links?.findAll { link ->
1073 link.type == "navigation" && link.path && !link.path.contains("?")
1074 }?.take(5)?.collect { [name: it.text, path: it.path] }
1075 if (navLinks) result.nav = navLinks
1076
1077 return result
1078 }
1079
734 // Resolve input screen path to simple path for lookup 1080 // Resolve input screen path to simple path for lookup
735 def inputScreenPath = screenPath 1081 def inputScreenPath = screenPath
736 if (screenPath.startsWith("component://")) { 1082 if (screenPath.startsWith("component://")) {
...@@ -852,76 +1198,42 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -852,76 +1198,42 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
852 } 1198 }
853 1199
854 // Regular screen rendering with current user context - use our custom ScreenTestImpl 1200 // Regular screen rendering with current user context - use our custom ScreenTestImpl
1201 // For compact/ARIA modes, we still render with MCP to get semantic data, then convert
1202 def actualScreenRenderMode = (renderMode == "aria" || renderMode == "compact" || renderMode == null) ? "mcp" : renderMode
855 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) 1203 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
856 .rootScreen(rootScreen) 1204 .rootScreen(rootScreen)
857 .renderMode(renderMode ?: "mcp") 1205 .renderMode(actualScreenRenderMode)
858 .auth(ec.user.username) 1206 .auth(ec.user.username)
859 1207
860 def renderParams = parameters ?: [:] 1208 def renderParams = parameters ?: [:]
861 renderParams.userId = ec.user.userId 1209 renderParams.userId = ec.user.userId
862 renderParams.username = ec.user.username 1210 renderParams.username = ec.user.username
863 1211
864 // --- Execute Action if specified --- 1212 // Build the screen path - append action/transition if specified
1213 // This lets the framework handle transition execution properly (inheritance, pre/post actions, etc.)
1214 def relativePath = testScreenPath
865 def actionResult = [:] 1215 def actionResult = [:]
866 if (action && resolvedScreenDef) {
867 ec.logger.info("MCP Screen Execution: Processing action '${action}' before rendering")
868 1216
1217 if (action && resolvedScreenDef) {
1218 // Verify the transition exists
869 def transition = resolvedScreenDef.getAllTransitions().find { it.getName() == action } 1219 def transition = resolvedScreenDef.getAllTransitions().find { it.getName() == action }
870 if (transition) { 1220 if (transition) {
1221 // Append transition to path - framework will execute it via recursiveRunTransition
1222 relativePath = "${testScreenPath}/${action}"
871 def serviceName = transition.getSingleServiceName() 1223 def serviceName = transition.getSingleServiceName()
872
873 if (serviceName) {
874 // Service action - execute service
875 ec.logger.info("MCP Screen Execution: Executing service action: ${serviceName}")
876 try {
877 def serviceParams = parameters ?: [:]
878 // Add standard context parameters
879 serviceParams.userId = ec.user.userId
880 serviceParams.username = ec.user.username
881
882 def svcResult = ec.service.sync().name(serviceName).parameters(serviceParams).call()
883
884 // Check for errors in execution context after service call
885 def hasError = ec.message.hasError()
886
887 if (hasError) {
888 actionResult = [ 1224 actionResult = [
889 action: action, 1225 action: action,
890 status: "error", 1226 service: serviceName,
891 message: ec.message.getErrorsString(), 1227 status: "pending" // Will be updated after render based on errors
892 result: null
893 ] 1228 ]
894 ec.logger.error("MCP Screen Execution: Service ${serviceName} completed with errors: ${ec.message.getErrorsString()}") 1229 ec.logger.info("MCP Screen Execution: Will execute transition '${action}' via framework (service: ${serviceName ?: 'none'})")
895 } else { 1230 } else {
896 actionResult = [ 1231 ec.logger.warn("MCP Screen Execution: Action '${action}' not found in screen transitions")
897 action: action,
898 status: "executed",
899 message: "Executed service ${serviceName}",
900 result: svcResult
901 ]
902 ec.logger.info("MCP Screen Execution: Service ${serviceName} executed successfully")
903 }
904 ec.logger.info("MCP Screen Execution: Transaction flushed after action execution")
905 } catch (Exception e) {
906 actionResult = [ 1232 actionResult = [
907 action: action, 1233 action: action,
908 status: "error", 1234 status: "error",
909 message: "Error executing service ${serviceName}: ${e.message}" 1235 message: "Transition '${action}' not found on screen"
910 ] 1236 ]
911 ec.logger.error("MCP Screen Execution: Error executing service ${serviceName}", e)
912 throw e
913 }
914 } else {
915 // Screen transition - just navigate, no action result
916 actionResult = [
917 action: action,
918 status: "transition",
919 message: "Screen transition to ${action}"
920 ]
921 ec.logger.info("MCP Screen Execution: Screen transition to ${action}")
922 }
923 } else {
924 ec.logger.warn("MCP Screen Execution: Action '${action}' not found in screen transitions")
925 } 1237 }
926 } 1238 }
927 1239
...@@ -929,14 +1241,56 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -929,14 +1241,56 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
929 ec.cache.clearAllCaches() 1241 ec.cache.clearAllCaches()
930 ec.logger.info("MCP Screen Execution: Entity cache cleared before rendering") 1242 ec.logger.info("MCP Screen Execution: Entity cache cleared before rendering")
931 1243
932 def relativePath = testScreenPath
933 ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}") 1244 ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}")
934 1245
935 def testRender = screenTest.render(relativePath, renderParams, "POST") 1246 def testRender = screenTest.render(relativePath, renderParams, "POST")
936 output = testRender.getOutput() 1247 output = testRender.getOutput()
937 1248
938 // --- Semantic State Extraction --- 1249 // --- Capture Action Result from Framework Execution ---
939 def postContext = testRender.getPostRenderContext() 1250 def postContext = testRender.getPostRenderContext()
1251
1252 if (action && actionResult.status == "pending") {
1253 // Check for errors from transition execution
1254 def errorMessages = testRender.getErrorMessages()
1255 def hasError = errorMessages && errorMessages.size() > 0
1256
1257 if (hasError) {
1258 actionResult.status = "error"
1259 actionResult.message = errorMessages.join("; ")
1260 ec.logger.error("MCP Screen Execution: Transition '${action}' completed with errors: ${actionResult.message}")
1261 } else {
1262 actionResult.status = "executed"
1263 actionResult.message = "Transition '${action}' executed successfully"
1264
1265 // Try to extract result from context (services often put results in context)
1266 // Common patterns: result, serviceResult, *Id (for create operations)
1267 def result = [:]
1268 if (postContext) {
1269 // Look for common result patterns
1270 ['result', 'serviceResult', 'createResult'].each { key ->
1271 if (postContext.containsKey(key)) {
1272 result[key] = postContext.get(key)
1273 }
1274 }
1275 // Look for created IDs (common pattern: partyId, productId, orderId, etc.)
1276 postContext.each { key, value ->
1277 if (key.toString().endsWith('Id') && value && !key.toString().startsWith('_')) {
1278 // Only include if it looks like a created/returned ID
1279 def keyStr = key.toString()
1280 if (!['userId', 'username', 'sessionId', 'requestId'].contains(keyStr)) {
1281 result[keyStr] = value
1282 }
1283 }
1284 }
1285 }
1286 if (result) {
1287 actionResult.result = result
1288 }
1289 ec.logger.info("MCP Screen Execution: Transition '${action}' executed successfully, result: ${result}")
1290 }
1291 }
1292
1293 // --- Semantic State Extraction ---
940 def semanticState = [:] 1294 def semanticState = [:]
941 1295
942 // Get final screen definition using resolved screen location 1296 // Get final screen definition using resolved screen location
...@@ -1057,7 +1411,49 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1057,7 +1411,49 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1057 1411
1058 // Build result based on renderMode 1412 // Build result based on renderMode
1059 def content = [] 1413 def content = []
1060 if ((renderMode == "mcp" || renderMode == "json") && semanticState) { 1414 if ((renderMode == null || renderMode == "compact") && semanticState) {
1415 // Return compact actionable format (default)
1416 def compactData = convertToCompactFormat(semanticState, screenPath)
1417
1418 // Include wiki summary if available
1419 if (wikiInstructions) {
1420 def wikiSummary = extractSummary(wikiInstructions)
1421 if (wikiSummary) compactData.help = wikiSummary
1422 }
1423
1424 // Add action result if an action was executed
1425 if (actionResult) {
1426 compactData.result = actionResult
1427 }
1428
1429 content << [
1430 type: "text",
1431 text: new groovy.json.JsonBuilder(compactData).toString()
1432 ]
1433 } else if (renderMode == "aria" && semanticState) {
1434 // Return ARIA accessibility tree format
1435 def ariaTree = convertToAriaTree(semanticState, screenPath)
1436 def ariaResult = [
1437 screenPath: screenPath,
1438 aria: ariaTree
1439 ]
1440
1441 // Include summary if available
1442 if (wikiInstructions) {
1443 def summary = extractSummary(wikiInstructions)
1444 if (summary) ariaResult.summary = summary
1445 }
1446
1447 // Add action result if an action was executed
1448 if (actionResult) {
1449 ariaResult.actionResult = actionResult
1450 }
1451
1452 content << [
1453 type: "text",
1454 text: new groovy.json.JsonBuilder(ariaResult).toString()
1455 ]
1456 } else if ((renderMode == "mcp" || renderMode == "json") && semanticState) {
1061 // Return structured MCP data 1457 // Return structured MCP data
1062 def mcpResult = [ 1458 def mcpResult = [
1063 screenPath: screenPath, 1459 screenPath: screenPath,
...@@ -1395,7 +1791,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1395,7 +1791,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1395 <in-parameters> 1791 <in-parameters>
1396 <parameter name="path" required="false"><description>Screen path to browse (e.g. 'PopCommerce'). Leave empty for root apps.</description></parameter> 1792 <parameter name="path" required="false"><description>Screen path to browse (e.g. 'PopCommerce'). Leave empty for root apps.</description></parameter>
1397 <parameter name="action"><description>Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name</description></parameter> 1793 <parameter name="action"><description>Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name</description></parameter>
1398 <parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter> 1794 <parameter name="renderMode" default="compact"><description>Render mode: compact (default, actionable summary), aria (accessibility tree), mcp (full metadata), text, html, xml, vuet, qvt</description></parameter>
1399 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter> 1795 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter>
1400 <parameter name="sessionId"/> 1796 <parameter name="sessionId"/>
1401 </in-parameters> 1797 </in-parameters>
...@@ -1637,71 +2033,10 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1637,71 +2033,10 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1637 } 2033 }
1638 2034
1639 // Process action before rendering 2035 // Process action before rendering
1640 def actionResult = null 2036 // Action execution is now handled by ScreenAsMcpTool via framework's transition handling
1641 def actionError = null 2037 // Just log that we're passing the action through
1642 2038 if (action) {
1643 if (action && resolvedScreenDef) { 2039 ec.logger.info("BrowseScreens: Passing action '${action}' to ScreenAsMcpTool for framework execution")
1644 try {
1645 ec.logger.info("BrowseScreens: Executing action '${action}' on ${currentPath}")
1646 def actionParams = parameters ?: [:]
1647
1648 if (action == "submit") {
1649 actionResult = [
1650 action: "submit",
1651 status: "success",
1652 message: "Form parameters submitted",
1653 parametersProcessed: actionParams.keySet()
1654 ]
1655 } else {
1656 def foundTransition = resolvedScreenDef.getAllTransitions().find { it.getName() == action }
1657
1658 if (foundTransition) {
1659 def serviceName = foundTransition.getSingleServiceName()
1660
1661 if (serviceName) {
1662 ec.logger.info("BrowseScreens: Executing service: ${serviceName}")
1663 def serviceCallResult = ec.service.sync().name(serviceName).parameters(actionParams).call()
1664
1665 // Check for errors in execution context after service call
1666 def hasError = ec.message.hasError()
1667
1668 if (hasError) {
1669 actionResult = [
1670 action: action,
1671 status: "error",
1672 message: ec.message.getErrorsString(),
1673 result: null
1674 ]
1675 ec.logger.error("BrowseScreens: Service ${serviceName} completed with errors: ${ec.message.getErrorsString()}")
1676 } else {
1677 actionResult = [
1678 action: action,
1679 status: "executed",
1680 message: "Executed service ${serviceName}",
1681 result: serviceCallResult
1682 ]
1683 }
1684 } else {
1685 actionResult = [
1686 action: action,
1687 status: "success",
1688 message: "Transition '${action}' found"
1689 ]
1690 }
1691 } else {
1692 // Fallback: check if it's a CRUD convention action for mantle entities
1693 def actionPrefix = action.size() > 6 ? action.take(6) : ""
1694 if (actionPrefix in ['create', 'update', 'delete']) {
1695 // Try to infer entity from screen context or parameters
1696 actionResult = [status: "error", message: "Dynamic CRUD not implemented without transition"]
1697 } else {
1698 actionError = "Transition '${action}' not found on screen ${currentPath}"
1699 }
1700 }
1701 }
1702 } catch (Exception e) {
1703 actionError = "Action execution failed: ${e.message}"
1704 }
1705 } 2040 }
1706 2041
1707 // Try to get wiki instructions for screen 2042 // Try to get wiki instructions for screen
...@@ -1714,7 +2049,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1714,7 +2049,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1714 // Render current screen if not root browsing 2049 // Render current screen if not root browsing
1715 def renderedContent = null 2050 def renderedContent = null
1716 def renderError = null 2051 def renderError = null
1717 def actualRenderMode = renderMode ?: "mcp" 2052 def actualRenderMode = renderMode ?: "compact"
1718 2053
1719 def resultMap = [ 2054 def resultMap = [
1720 currentPath: currentPath, 2055 currentPath: currentPath,
...@@ -1728,9 +2063,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1728,9 +2063,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1728 2063
1729 // Pass forward-slash path directly to ScreenAsMcpTool 2064 // Pass forward-slash path directly to ScreenAsMcpTool
1730 // ScreenAsMcpTool will use Moqui's ScreenUrlInfo.parseSubScreenPath to navigate through screen hierarchy 2065 // ScreenAsMcpTool will use Moqui's ScreenUrlInfo.parseSubScreenPath to navigate through screen hierarchy
2066 // Action is passed through for framework-based transition execution
1731 def browseScreenCallParams = [ 2067 def browseScreenCallParams = [
1732 path: path, 2068 path: path,
1733 parameters: parameters ?: [:], 2069 parameters: parameters ?: [:],
2070 action: action, // Let ScreenAsMcpTool handle via framework
1734 renderMode: actualRenderMode, 2071 renderMode: actualRenderMode,
1735 sessionId: sessionId 2072 sessionId: sessionId
1736 ] 2073 ]
...@@ -1755,7 +2092,18 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1755,7 +2092,18 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1755 } 2092 }
1756 } 2093 }
1757 2094
1758 if (resultObj && resultObj.semanticState) { 2095 // Handle compact mode - pass through compact data directly
2096 if ((actualRenderMode == "compact" || actualRenderMode == null) && resultObj && resultObj.screen) {
2097 // Compact mode returns flat structure, merge it into resultMap
2098 resultObj.each { k, v -> if (k != "screen") resultMap[k] = v }
2099 resultMap.screen = resultObj.screen
2100 ec.logger.info("BrowseScreens: Compact mode - passing through for ${currentPath}")
2101 // Handle ARIA mode - pass through the aria tree directly
2102 } else if (actualRenderMode == "aria" && resultObj && resultObj.aria) {
2103 resultMap.aria = resultObj.aria
2104 if (resultObj.summary) resultMap.summary = resultObj.summary
2105 ec.logger.info("BrowseScreens: ARIA mode - passing through aria tree for ${currentPath}")
2106 } else if (resultObj && resultObj.semanticState) {
1759 resultMap.semanticState = resultObj.semanticState 2107 resultMap.semanticState = resultObj.semanticState
1760 2108
1761 // Build UI narrative for LLM guidance 2109 // Build UI narrative for LLM guidance
...@@ -1788,9 +2136,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1788,9 +2136,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1788 resultMap.actionResult = actionResult 2136 resultMap.actionResult = actionResult
1789 } 2137 }
1790 2138
1791 // Don't include renderedContent for renderMode "mcp" - semanticState provides structured data 2139 // Don't include renderedContent for renderMode "mcp", "aria", or "compact" - structured data is provided instead
1792 // Including both duplicates data and truncation breaks JSON structure 2140 // Including both duplicates data and truncation breaks JSON structure
1793 if (renderedContent && actualRenderMode != "mcp") { 2141 if (renderedContent && actualRenderMode != "mcp" && actualRenderMode != "aria" && actualRenderMode != "compact") {
1794 resultMap.renderedContent = renderedContent 2142 resultMap.renderedContent = renderedContent
1795 } 2143 }
1796 2144
...@@ -1897,7 +2245,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1897,7 +2245,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1897 properties: [ 2245 properties: [
1898 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], 2246 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"],
1899 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], 2247 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"],
1900 "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"], 2248 "renderMode": [type: "string", description: "Render mode: compact (default, actionable summary), aria (accessibility tree), mcp (full metadata), text, html, xml, vuet, qvt"],
1901 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"] 2249 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"]
1902 ] 2250 ]
1903 ] 2251 ]
......
...@@ -324,6 +324,7 @@ class WebFacadeStub implements WebFacade { ...@@ -324,6 +324,7 @@ class WebFacadeStub implements WebFacade {
324 private String screenPath 324 private String screenPath
325 private String remoteUser = null 325 private String remoteUser = null
326 private java.security.Principal userPrincipal = null 326 private java.security.Principal userPrincipal = null
327 private Map<String, Object> attributes = [:]
327 328
328 MockHttpServletRequest(Map<String, Object> parameters, String method, HttpSession session = null, String screenPath = null) { 329 MockHttpServletRequest(Map<String, Object> parameters, String method, HttpSession session = null, String screenPath = null) {
329 this.parameters = parameters ?: [:] 330 this.parameters = parameters ?: [:]
...@@ -331,6 +332,10 @@ class WebFacadeStub implements WebFacade { ...@@ -331,6 +332,10 @@ class WebFacadeStub implements WebFacade {
331 this.session = session 332 this.session = session
332 this.screenPath = screenPath 333 this.screenPath = screenPath
333 334
335 // Mark request as authenticated for MCP - bypasses CSRF token check for transitions
336 // This is safe because MCP requests are already authenticated via the MCP session
337 this.attributes["moqui.request.authenticated"] = "true"
338
334 // Extract user information from session attributes for authentication 339 // Extract user information from session attributes for authentication
335 if (session) { 340 if (session) {
336 def username = session.getAttribute("username") 341 def username = session.getAttribute("username")
...@@ -385,9 +390,9 @@ class WebFacadeStub implements WebFacade { ...@@ -385,9 +390,9 @@ class WebFacadeStub implements WebFacade {
385 @Override String getProtocol() { return "HTTP/1.1" } 390 @Override String getProtocol() { return "HTTP/1.1" }
386 391
387 // Other required methods with minimal implementations 392 // Other required methods with minimal implementations
388 @Override Object getAttribute(String name) { return null } 393 @Override Object getAttribute(String name) { return attributes.get(name) }
389 @Override void setAttribute(String name, Object value) {} 394 @Override void setAttribute(String name, Object value) { attributes[name] = value }
390 @Override void removeAttribute(String name) {} 395 @Override void removeAttribute(String name) { attributes.remove(name) }
391 @Override java.util.Enumeration<String> getAttributeNames() { return Collections.enumeration([]) } 396 @Override java.util.Enumeration<String> getAttributeNames() { return Collections.enumeration([]) }
392 @Override String getAuthType() { return null } 397 @Override String getAuthType() { return null }
393 @Override String getRemoteUser() { return remoteUser } 398 @Override String getRemoteUser() { return remoteUser }
......