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
Showing
2 changed files
with
480 additions
and
127 deletions
| ... | @@ -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 } | ... | ... |
-
Please register or sign in to post a comment