9f389520 by Ean Schuessler

Consolidate screen routing into a single unified tool

- Replace redundant screen-specific tools with moqui_render_screen
- Simplify ToolsCall dispatcher and remove dead protocol mapping logic
- Improve GetScreenDetails to extract parameters from XML and entities
- Fix Basic auth and session handling in EnhancedMcpServlet
- Remove obsolete name decoding logic from McpUtils
1 parent 1bb29d0c
...@@ -128,78 +128,86 @@ ...@@ -128,78 +128,86 @@
128 <actions> 128 <actions>
129 <script><![CDATA[ 129 <script><![CDATA[
130 import org.moqui.context.ExecutionContext 130 import org.moqui.context.ExecutionContext
131 import org.moqui.impl.context.UserFacadeImpl.UserInfo
132 import groovy.json.JsonBuilder 131 import groovy.json.JsonBuilder
133 132
134 ExecutionContext ec = context.ec 133 ExecutionContext ec = context.ec
135 134
136 // Start timing for execution metrics 135 // Start timing for execution metrics
137 def startTime = System.currentTimeMillis() 136 def startTime = System.currentTimeMillis()
137 def isError = false
138 138
139 // Handle stubbed MCP protocol methods by routing to actual Moqui services 139 try {
140 def protocolMethodMappings = [ 140 // Consolidated Tool Dispatching
141 "tools/list": "McpServices.list#Tools", 141
142 "tools/call": "McpServices.mcp#ToolsCall", 142 if (name == "moqui_render_screen") {
143 "resources/list": "McpServices.mcp#ResourcesList", 143 def screenPath = arguments?.path
144 "resources/read": "McpServices.mcp#ResourcesRead", 144 def parameters = arguments?.parameters ?: [:]
145 "resources/subscribe": "McpServices.mcp#ResourcesSubscribe", 145 def renderMode = arguments?.renderMode ?: "html"
146 "resources/unsubscribe": "McpServices.mcp#ResourcesUnsubscribe", 146 def subscreenName = arguments?.subscreenName
147 "prompts/list": "McpServices.mcp#PromptsList",
148 "prompts/get": "McpServices.mcp#PromptsGet",
149 "ping": "McpServices.mcp#Ping",
150 "moqui_browse_screens": "McpServices.mcp#BrowseScreens",
151 "moqui_search_screens": "McpServices.mcp#SearchScreens",
152 "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails"
153 ]
154 147
155 if (protocolMethodMappings.containsKey(name)) { 148 if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter")
156 ec.logger.info("MCP ToolsCall: Routing protocol method ${name} to ${protocolMethodMappings[name]}")
157 def targetServiceName = protocolMethodMappings[name]
158 149
159 if (name == "tools/call") { 150 ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}")
160 def actualToolName = arguments?.name
161 def actualArguments = arguments?.arguments
162 if (!actualToolName) throw new Exception("tools/call requires 'name' parameter in arguments")
163 151
164 if (actualArguments instanceof Map) actualArguments.sessionId = sessionId 152 // Handle component:// or simple dot notation path
165 else actualArguments = [sessionId: sessionId] 153 def resolvedPath = screenPath
154 def resolvedSubscreen = subscreenName
166 155
167 // Check if this is a screen tool (starts with moqui_) 156 if (!resolvedPath.startsWith("component://")) {
168 def screenPath = null 157 // Simple dot notation or path conversion
169 def subscreenName = null 158 // Longest prefix match for XML screen files
170 if (actualToolName.startsWith("moqui_")) { 159 def pathParts = resolvedPath.split('\\.')
171 def decoded = org.moqui.mcp.McpUtils.decodeToolName(actualToolName, ec) 160 def componentName = pathParts[0]
172 screenPath = decoded.screenPath 161 def bestPath = null
173 subscreenName = decoded.subscreenName 162 def bestSubscreen = null
163
164 // Start from the longest possible XML path and work backwards
165 for (int i = pathParts.size(); i >= 1; i--) {
166 def currentTry = "component://${componentName}/screen/" + (i > 1 ? pathParts[1..<i].join('/') : componentName) + ".xml"
167 if (ec.resource.getLocationReference(currentTry).getExists()) {
168 bestPath = currentTry
169 if (i < pathParts.size()) {
170 bestSubscreen = pathParts[i..-1].join('_')
171 }
172 break
173 }
174 }
175
176 if (bestPath) {
177 resolvedPath = bestPath
178 resolvedSubscreen = bestSubscreen
179 } else {
180 // Fallback to original logic if nothing found
181 resolvedPath = "component://${componentName}/screen/${componentName}.xml"
182 resolvedSubscreen = pathParts.size() > 1 ? pathParts[1..-1].join('_') : null
183 }
174 } 184 }
175 185
176 if (screenPath) {
177 ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}, subscreen=${subscreenName}")
178 def screenCallParams = [ 186 def screenCallParams = [
179 screenPath: screenPath, 187 screenPath: resolvedPath,
180 parameters: actualArguments, 188 parameters: parameters,
181 renderMode: actualArguments?.renderMode ?: "html", 189 renderMode: renderMode,
182 sessionId: sessionId 190 sessionId: sessionId
183 ] 191 ]
184 if (subscreenName) screenCallParams.subscreenName = subscreenName 192 if (resolvedSubscreen) screenCallParams.subscreenName = resolvedSubscreen
185 193
186 return ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") 194 def serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
187 .parameters(screenCallParams).call() 195 .parameters(screenCallParams).call()
188 } else { 196
189 def actualTargetServiceName = protocolMethodMappings[actualToolName] 197 // ScreenAsMcpTool returns the final result map directly
190 if (actualTargetServiceName) { 198 result = serviceResult
191 return ec.service.sync().name(actualTargetServiceName) 199 return
192 .parameters(actualArguments ?: [:]).call()
193 } else {
194 // Fallback: check if it's a Moqui service
195 if (ec.service.isServiceDefined(actualToolName)) {
196 def serviceResult = ec.service.sync().name(actualToolName).parameters(actualArguments ?: [:]).call()
197 return [result: [content: [[type: "text", text: new JsonBuilder(serviceResult).toString()]], isError: false]]
198 }
199 throw new Exception("Unknown tool name: ${actualToolName}")
200 }
201 } 200 }
202 } else { 201
202 // Handle internal discovery/utility tools
203 def internalToolMappings = [
204 "moqui_browse_screens": "McpServices.mcp#BrowseScreens",
205 "moqui_search_screens": "McpServices.mcp#SearchScreens",
206 "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails"
207 ]
208
209 def targetServiceName = internalToolMappings[name]
210 if (targetServiceName) {
203 def serviceResult = ec.service.sync().name(targetServiceName).parameters(arguments ?: [:]).call() 211 def serviceResult = ec.service.sync().name(targetServiceName).parameters(arguments ?: [:]).call()
204 def actualRes = serviceResult?.result ?: serviceResult 212 def actualRes = serviceResult?.result ?: serviceResult
205 // Ensure standard MCP response format with content array 213 // Ensure standard MCP response format with content array
...@@ -210,99 +218,19 @@ ...@@ -210,99 +218,19 @@
210 } 218 }
211 return 219 return
212 } 220 }
213 }
214
215 // Check if this is a screen-based tool using McpUtils
216 def screenPath = null
217 def subscreenName = null
218 if (name.startsWith("moqui_")) {
219 def decoded = org.moqui.mcp.McpUtils.decodeToolName(name, ec)
220 screenPath = decoded.screenPath
221 subscreenName = decoded.subscreenName
222 }
223
224 if (screenPath) {
225 ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}, subscreen: ${subscreenName}")
226
227 def screenParams = arguments ?: [:]
228 def renderMode = screenParams.remove('renderMode') ?: "html"
229 def serviceCallParams = [screenPath: screenPath, parameters: screenParams, renderMode: renderMode, sessionId: sessionId]
230 if (subscreenName) serviceCallParams.subscreenName = subscreenName
231
232 ec.logger.info("SCREENASTOOL ${serviceCallParams}")
233 def serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
234 .parameters(serviceCallParams)
235 .call()
236
237 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
238
239 // Convert result to MCP format
240 def content = []
241 221
242 if (serviceResult?.result) { 222 // Fallback: check if it's a general Moqui service (non-screen-based tools)
243 // Handle screen execution result which has content array 223 if (ec.service.isServiceDefined(name)) {
244 if (serviceResult.result.content && serviceResult.result.content instanceof List) { 224 // Execute service with current user context
245 // Use the content array directly from the screen execution result 225 def serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call()
246 content.addAll(serviceResult.result.content) 226 // Convert result to MCP format for general services
247 } else if (serviceResult.result.type == "text" && serviceResult.result.text) { 227 result = [content: [[type: "text", text: new JsonBuilder(serviceResult).toString()]], isError: false]
248 content << [
249 type: "text",
250 text: serviceResult.result.text
251 ]
252 } else {
253 content << [
254 type: "text",
255 text: serviceResult.result.text ?: serviceResult.result.toString() ?: "Screen executed successfully"
256 ]
257 }
258 }
259
260 result = [
261 content: content,
262 isError: false
263 ]
264 return 228 return
265 } 229 }
266 230
267 // For service tools, validate service exists 231 throw new Exception("Unknown tool name: ${name}")
268 if (!ec.service.isServiceDefined(name)) { 232 } catch (Exception e) {
269 throw new Exception("Tool not found: ${name}") 233 isError = true
270 }
271
272 // Capture original user for permission context
273 def originalUsername = ec.user.username
274 UserInfo adminUserInfo = null
275
276 try {
277 // Execute service with elevated privileges for system access
278 // but maintain audit context with actual user
279 def serviceResult
280 adminUserInfo = null
281 try {
282 serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call()
283 } finally {
284 if (adminUserInfo != null) {
285 ec.user.popUser()
286 }
287 ec.artifactExecution.enableAuthz()
288 }
289 executionTime = (System.currentTimeMillis() - startTime) / 1000.0
290
291 // Convert result to MCP format
292 content = []
293 if (serviceResult) {
294 content << [
295 type: "text",
296 text: new JsonBuilder(serviceResult).toString()
297 ]
298 }
299
300 result = [
301 content: content,
302 isError: serviceResult?.result?.isError ?: false
303 ]
304 } catch (Exception e2) {
305 executionTime = (System.currentTimeMillis() - startTime) / 1000.0
306 234
307 result = [ 235 result = [
308 content: [ 236 content: [
...@@ -316,27 +244,20 @@ ...@@ -316,27 +244,20 @@
316 244
317 ec.logger.error("MCP tool execution error", e) 245 ec.logger.error("MCP tool execution error", e)
318 } finally { 246 } finally {
319 // Always restore original user context
320 if (adminUserInfo != null) {
321 ec.user.popUser()
322 }
323
324 // Send a simple notification about tool execution 247 // Send a simple notification about tool execution
325 try { 248 try {
326 def servlet = ec.web.getServletContext().getAttribute("enhancedMcpServlet") 249 def servlet = ec.web.getServletContext().getAttribute("enhancedMcpServlet")
327 ec.logger.info("TOOLS CALL: Got servlet reference: ${servlet != null}, sessionId: ${sessionId}")
328 if (servlet && sessionId) { 250 if (servlet && sessionId) {
329 def notification = [ 251 def notification = [
330 method: "notifications/tool_execution", 252 method: "notifications/tool_execution",
331 params: [ 253 params: [
332 toolName: name, 254 toolName: name,
333 executionTime: (System.currentTimeMillis() - startTime) / 1000.0, 255 executionTime: (System.currentTimeMillis() - startTime) / 1000.0,
334 success: !result?.result?.isError, 256 success: !isError,
335 timestamp: System.currentTimeMillis() 257 timestamp: System.currentTimeMillis()
336 ] 258 ]
337 ] 259 ]
338 servlet.queueNotification(sessionId, notification) 260 servlet.queueNotification(sessionId, notification)
339 ec.logger.info("Queued tool execution notification for session ${sessionId}")
340 } 261 }
341 } catch (Exception e) { 262 } catch (Exception e) {
342 ec.logger.warn("Failed to send tool execution notification: ${e.message}") 263 ec.logger.warn("Failed to send tool execution notification: ${e.message}")
...@@ -690,10 +611,6 @@ import groovy.json.JsonBuilder ...@@ -690,10 +611,6 @@ import groovy.json.JsonBuilder
690 ExecutionContext ec = context.ec 611 ExecutionContext ec = context.ec
691 612
692 def startTime = System.currentTimeMillis() 613 def startTime = System.currentTimeMillis()
693 // Note: Screen validation will happen during render
694 // if (!ec.screen.isScreenDefined(screenPath)) {
695 // throw new Exception("Screen not found: ${screenPath}")
696 // }
697 614
698 // Set parameters in context 615 // Set parameters in context
699 if (parameters) { 616 if (parameters) {
...@@ -709,352 +626,111 @@ def startTime = System.currentTimeMillis() ...@@ -709,352 +626,111 @@ def startTime = System.currentTimeMillis()
709 ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen") 626 ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen")
710 627
711 // For ScreenTest to work properly, we need to use the correct root screen 628 // For ScreenTest to work properly, we need to use the correct root screen
712 // The screenPath should be relative to the appropriate root screen
713 def testScreenPath = screenPath 629 def testScreenPath = screenPath
714 def rootScreen = "component://webroot/screen/webroot.xml" 630 def rootScreen = "component://webroot/screen/webroot.xml"
715 631
716 // Initialize standalone flag outside the if block
717 def targetScreenDef = null 632 def targetScreenDef = null
718 def isStandalone = false 633 def isStandalone = false
719 634
635 if (screenPath.startsWith("component://")) {
720 def pathAfterComponent = screenPath.substring(12).replace('.xml','') // Remove "component://" 636 def pathAfterComponent = screenPath.substring(12).replace('.xml','') // Remove "component://"
721 def pathParts = pathAfterComponent.split("/") 637 def pathParts = pathAfterComponent.split("/")
722 // If the screen path is already a full component:// path, we need to handle it differently
723 if (screenPath.startsWith("component://")) {
724 // For component:// paths, we need to use the component's root screen, not webroot
725 // Extract the component name and use its root screen
726 if (pathParts.length >= 2) {
727 def componentName = pathParts[0]
728 def remainingPath = pathParts[1..-1].join("/")
729 638
730 // Check if the target screen itself is standalone FIRST 639 // Check if the target screen itself is standalone
731 try { 640 try {
732 targetScreenDef = ec.screen.getScreenDefinition(screenPath) 641 targetScreenDef = ec.screen.getScreenDefinition(screenPath)
733 ec.logger.info("MCP Screen Execution: Target screen def for ${screenPath}: ${targetScreenDef?.getClass()?.getSimpleName()}")
734 if (targetScreenDef?.screenNode) { 642 if (targetScreenDef?.screenNode) {
735 def standaloneAttr = targetScreenDef.screenNode.attribute('standalone') 643 def standaloneAttr = targetScreenDef.screenNode.attribute('standalone')
736 ec.logger.info("MCP Screen Execution: Target screen ${screenPath} standalone attribute: '${standaloneAttr}'")
737 isStandalone = standaloneAttr == "true" 644 isStandalone = standaloneAttr == "true"
738 } else {
739 ec.logger.warn("MCP Screen Execution: Target screen ${screenPath} has no screenNode")
740 } 645 }
741 ec.logger.info("MCP Screen Execution: Target screen ${screenPath} standalone=${isStandalone}")
742 646
743 if (isStandalone) { 647 if (isStandalone) {
744 // For standalone screens, try to render with minimal context or fall back to URL
745 ec.logger.info("MCP Screen Execution: Standalone screen detected, will try direct rendering")
746 rootScreen = screenPath 648 rootScreen = screenPath
747 testScreenPath = "" // Empty path for standalone screens 649 testScreenPath = ""
748 // We'll handle standalone screens specially below
749 } 650 }
750 } catch (Exception e) { 651 } catch (Exception e) {
751 ec.logger.warn("MCP Screen Execution: Error checking target screen ${screenPath}: ${e.message}") 652 ec.logger.warn("MCP Screen Execution: Error checking target screen ${screenPath}: ${e.message}")
752 ec.logger.error("MCP Screen Execution: Full exception", e)
753 } 653 }
754 654
755 // Only look for component root if target is not standalone
756 if (!isStandalone) { 655 if (!isStandalone) {
757 // For component://webroot/screen/... paths, always use webroot as root 656 // Check if the screen path itself is a valid screen definition
657 try {
658 if (ec.screen.getScreenDefinition(screenPath)) {
659 rootScreen = screenPath
660 testScreenPath = ""
661 } else {
662 // Original component root logic
758 if (pathAfterComponent.startsWith("webroot/screen/")) { 663 if (pathAfterComponent.startsWith("webroot/screen/")) {
759 // This is a webroot screen, use webroot as root and the rest as path
760 rootScreen = "component://webroot/screen/webroot.xml" 664 rootScreen = "component://webroot/screen/webroot.xml"
761 testScreenPath = pathAfterComponent.substring("webroot/screen/".length()) 665 testScreenPath = pathAfterComponent.substring("webroot/screen/".length())
762 // Remove any leading "webroot/" from the path since we're already using webroot as root
763 if (testScreenPath.startsWith("webroot/")) { 666 if (testScreenPath.startsWith("webroot/")) {
764 testScreenPath = testScreenPath.substring("webroot/".length()) 667 testScreenPath = testScreenPath.substring("webroot/".length())
765 } 668 }
766 ec.logger.info("MCP Screen Execution: Using webroot root for webroot screen: ${rootScreen} with path: ${testScreenPath}")
767 } else { 669 } else {
768 // For other component screens, check if this is a direct screen path (not a subscreen path) 670 def componentName = pathParts[0]
769 def pathSegments = remainingPath.split("/") 671 def remainingPath = pathParts[1..-1].join("/")
770 def isDirectScreenPath = false
771
772 // Try to check if the full path is a valid screen
773 try {
774 def directScreenDef = ec.screen.getScreenDefinition(screenPath)
775 if (directScreenDef) {
776 isDirectScreenPath = true
777 ec.logger.info("MCP Screen Execution: Found direct screen path: ${screenPath}")
778 }
779 } catch (Exception e) {
780 ec.logger.debug("MCP Screen Execution: Direct screen check failed for ${screenPath}: ${e.message}")
781 }
782 672
783 if (isDirectScreenPath) {
784 // For direct screen paths, use the screen itself as root
785 rootScreen = screenPath
786 testScreenPath = ""
787 ec.logger.info("MCP Screen Execution: Using direct screen as root: ${rootScreen}")
788 } else {
789 // Try to find the actual root screen for this component 673 // Try to find the actual root screen for this component
790 def componentRootScreen = null 674 def componentRootScreen = null
791 def possibleRootScreens = [ 675 def possibleRootScreens = ["${componentName}.xml", "${componentName}Admin.xml", "${componentName}Root.xml"]
792 "${componentName}.xml",
793 "${componentName}Admin.xml",
794 "${componentName}Root.xml"
795 ]
796 676
797 for (rootScreenName in possibleRootScreens) { 677 for (rootScreenName in possibleRootScreens) {
798 def candidateRoot = "component://${componentName}/screen/${rootScreenName}" 678 def candidateRoot = "component://${componentName}/screen/${rootScreenName}"
799 try { 679 try {
800 def testDef = ec.screen.getScreenDefinition(candidateRoot) 680 if (ec.screen.getScreenDefinition(candidateRoot)) {
801 if (testDef) {
802 componentRootScreen = candidateRoot 681 componentRootScreen = candidateRoot
803 ec.logger.info("MCP Screen Execution: Found component root screen: ${componentRootScreen}")
804 break 682 break
805 } 683 }
806 } catch (Exception e) { 684 } catch (Exception e) {}
807 ec.logger.debug("MCP Screen Execution: Root screen ${candidateRoot} not found: ${e.message}")
808 }
809 } 685 }
810 686
811 if (componentRootScreen) { 687 if (componentRootScreen) {
812 rootScreen = componentRootScreen 688 rootScreen = componentRootScreen
813 testScreenPath = remainingPath 689 testScreenPath = remainingPath
814 ec.logger.info("MCP Screen Execution: Using component root ${rootScreen} for path ${testScreenPath}")
815 } else {
816 // For mantle and other components, try using the component's screen directory as root
817 // This is a better fallback than webroot
818 def componentScreenRoot = "component://${componentName}/screen/"
819 if (pathAfterComponent.startsWith("${componentName}/screen/")) {
820 // Extract the screen file name from the path
821 def screenFileName = pathAfterComponent.substring("${componentName}/screen/".length())
822 rootScreen = screenPath // Use the full path as root
823 testScreenPath = "" // Empty path for direct screen access
824 ec.logger.info("MCP Screen Execution: Using component screen as direct root: ${rootScreen}")
825 } else { 690 } else {
826 // Final fallback: try webroot 691 rootScreen = screenPath
827 rootScreen = "component://webroot/screen/webroot.xml" 692 testScreenPath = ""
828 testScreenPath = pathAfterComponent
829 ec.logger.warn("MCP Screen Execution: Could not find component root for ${componentName}, using webroot fallback: ${testScreenPath}")
830 }
831 } 693 }
832 } 694 }
833 } 695 }
696 } catch (Exception e) {
697 // Same as above fallback
698 rootScreen = screenPath
699 testScreenPath = ""
834 } 700 }
835 } else {
836 // Fallback for malformed component paths
837 testScreenPath = pathAfterComponent
838 ec.logger.warn("MCP Screen Execution: Malformed component path, using fallback: ${testScreenPath}")
839 } 701 }
840 } 702 }
841 703
842 // Handle subscreen if specified
843 if (subscreenName) {
844 ec.logger.info("MCP Screen Execution: Handling subscreen ${subscreenName} for parent ${screenPath}")
845
846 // For subscreens, we need to modify the render path to include the subscreen
847 // The pathParts array already contains the full path, so we need to add the subscreen name
848 def subscreenPathParts = pathParts + subscreenName.split('_')
849 ec.logger.info("MCP Screen Execution: Full subscreen path parts: ${subscreenPathParts}")
850
851 // User context should already be correct from MCP servlet restoration
852 // CustomScreenTestImpl will capture current user context automatically
853 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}")
854
855 // Regular screen rendering with current user context - use our custom ScreenTestImpl 704 // Regular screen rendering with current user context - use our custom ScreenTestImpl
856 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) 705 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
857 .rootScreen(rootScreen) 706 .rootScreen(rootScreen)
858 .renderMode(renderMode ? renderMode : "html") 707 .renderMode(renderMode ? renderMode : "html")
859 .auth(ec.user.username) // Propagate current user to the test renderer 708 .auth(ec.user.username)
860
861 ec.logger.info("MCP Screen Execution: ScreenTest object created for subscreen: ${screenTest?.getClass()?.getSimpleName()}")
862 709
863 if (screenTest) {
864 def renderParams = parameters ?: [:] 710 def renderParams = parameters ?: [:]
865
866 // Add current user info to render context to maintain authentication
867 renderParams.userId = ec.user.userId 711 renderParams.userId = ec.user.userId
868 renderParams.username = ec.user.username 712 renderParams.username = ec.user.username
869 713
870 // Set user context in ScreenTest to maintain authentication 714 def relativePath = subscreenName ? subscreenName.replaceAll('_','/') : testScreenPath
871 // Note: ScreenTestImpl may not have direct userAccountId property, 715 ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}")
872 // the user context should be inherited from the current ExecutionContext
873 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}")
874 ec.logger.info("SUBSCREEN PATH PARTS ${subscreenPathParts}")
875 716
876 // Regular screen rendering with timeout for subscreen
877 try {
878 // Construct the proper relative path from parent screen to target subscreen
879 // The subscreenName contains the full path from parent with underscores, convert to proper path
880 def relativePath = subscreenName.replaceAll('_','/')
881 ec.logger.info("TESTRENDER ${relativePath} ${renderParams}")
882 // For subscreens, use the full relative path from parent screen to target subscreen
883 def testRender = screenTest.render(relativePath, renderParams, "POST") 717 def testRender = screenTest.render(relativePath, renderParams, "POST")
884 output = testRender.getOutput() 718 output = testRender.getOutput()
885 def outputLength = output?.length() ?: 0
886 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}") 719 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
887 } catch (java.util.concurrent.TimeoutException e) {
888 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}")
889 }
890 } else {
891 throw new Exception("ScreenTest object is null")
892 }
893 } else {
894 // Direct screen execution (no subscreen)
895 ec.logger.info("MCP Screen Execution: Rendering direct screen ${screenPath} (root: ${rootScreen}, path: ${testScreenPath})")
896
897 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
898 .rootScreen(rootScreen)
899 .renderMode(renderMode ? renderMode : "html")
900 .auth(ec.user.username)
901
902 if (screenTest) {
903 def renderParams = parameters ?: [:]
904 renderParams.userId = ec.user.userId
905 renderParams.username = ec.user.username
906 720
907 try {
908 ec.logger.info("TESTRENDER ${testScreenPath} ${renderParams}")
909 def testRender = screenTest.render(testScreenPath, renderParams, "POST")
910 output = testRender.getOutput()
911 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
912 } catch (java.util.concurrent.TimeoutException e) {
913 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}")
914 }
915 }
916 }
917 } catch (Exception e) { 721 } catch (Exception e) {
918 isError = true 722 isError = true
919 ec.logger.warn("MCP Screen Execution: Could not render screen ${screenPath}, exposing error details: ${e.message}")
920 ec.logger.warn("MCP Screen Execution: Exception details: ${e.getClass()?.getSimpleName()}: ${e.getMessage()}")
921 ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e) 723 ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
922 724 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"
923 // Expose detailed error information instead of URL fallback
924 def errorDetails = []
925 errorDetails << "SCREEN RENDERING ERROR"
926 errorDetails << "======================"
927 errorDetails << "Screen Path: ${screenPath}"
928 errorDetails << "Error Type: ${e.getClass()?.getSimpleName()}"
929 errorDetails << "Error Message: ${e.getMessage()}"
930 errorDetails << ""
931
932 // Add stack trace for debugging (limited depth for readability)
933 if (e.getStackTrace()) {
934 errorDetails << "Stack Trace (top 10 frames):"
935 e.getStackTrace().take(10).eachWithIndex { stackTrace, index ->
936 errorDetails << " ${index + 1}. ${stackTrace.toString()}"
937 }
938 if (e.getStackTrace().size() > 10) {
939 errorDetails << " ... and ${e.getStackTrace().size() - 10} more frames"
940 }
941 }
942
943 // Add cause information if available
944 def cause = e.getCause()
945 if (cause) {
946 errorDetails << ""
947 errorDetails << "Root Cause: ${cause.getClass()?.getSimpleName()}: ${cause.getMessage()}"
948 }
949
950 // Add context information
951 errorDetails << ""
952 errorDetails << "Context Information:"
953 errorDetails << "- User: ${ec.user.username} (${ec.user.userId})"
954 errorDetails << "- Render Mode: ${renderMode}"
955 errorDetails << "- Parameters: ${parameters ?: 'none'}"
956 errorDetails << "- Execution Time: ${((System.currentTimeMillis() - startTime) / 1000.0)}s"
957
958 // Add troubleshooting suggestions
959 errorDetails << ""
960 errorDetails << "Troubleshooting Suggestions:"
961 errorDetails << "1. Check if the screen path is correct and the screen exists"
962 errorDetails << "2. Verify user has permission to access this screen"
963 errorDetails << "3. Check if all required parameters are provided"
964 errorDetails << "4. Verify screen dependencies and data access"
965 errorDetails << "5. Check server logs for more detailed error information"
966
967 output = errorDetails.join("\n")
968 }
969
970 // Helper function to convert web paths to MCP tool names
971 def convertWebPathToMcpTool = { path ->
972 try {
973 ec.logger.info("Converting web path to MCP tool: ${path}")
974 // Handle simple catalog paths (dropdown menu items)
975 if (path == "Category" || path.startsWith("Category/") || path == "Product/FindProduct/getCategoryList") {
976 return "moqui_SimpleScreens_SimpleScreens_Catalog_Category"
977 } else if (path == "Feature" || path.startsWith("Feature/")) {
978 return "moqui_SimpleScreens_SimpleScreens_Catalog_Feature"
979 } else if (path == "FeatureGroup" || path.startsWith("FeatureGroup/")) {
980 return "moqui_SimpleScreens_SimpleScreens_Catalog_FeatureGroup"
981 } else if (path == "Product" || path.startsWith("Product/")) {
982 return "moqui_SimpleScreens_SimpleScreens_Catalog_Product"
983 } else if (path.startsWith("Search")) {
984 return "moqui_SimpleScreens_SimpleScreens_Search"
985 }
986
987 // Handle full catalog paths
988 if (path.startsWith("Product/FindProduct")) {
989 return "screen_SimpleScreens_screen_SimpleScreens_Catalog_Product"
990 } else if (path.startsWith("Category/FindCategory")) {
991 return "screen_SimpleScreens_screen_SimpleScreens_Catalog_Category"
992 } else if (path.startsWith("Feature/FindFeature")) {
993 return "screen_SimpleScreens_screen_SimpleScreens_Catalog_Feature"
994 } else if (path.startsWith("FeatureGroup/FindFeatureGroup")) {
995 return "screen_SimpleScreens_screen_SimpleScreens_Catalog_FeatureGroup"
996 }
997
998 // Handle PopCommerce Admin paths
999 if (path.startsWith("PopcAdmin/")) {
1000 def cleanPath = path.replace("PopcAdmin/", "PopCommerceAdmin/")
1001 def toolName = "screen_PopCommerce_screen_" + cleanPath.replace("/", "_")
1002 return toolName
1003 }
1004
1005 // Handle SimpleScreens paths
1006 if (path.startsWith("apps/")) {
1007 def cleanPath = path.replace("apps/", "")
1008 def toolName = "screen_SimpleScreens_screen_SimpleScreens_" + cleanPath.replace("/", "_")
1009 return toolName
1010 }
1011
1012 return null
1013 } catch (Exception e) {
1014 ec.logger.debug("Error converting web path ${path} to MCP tool: ${e.message}")
1015 return null
1016 }
1017 } 725 }
1018 726
1019 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 727 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
1020 728
1021 // Convert web URLs to MCP tool suggestions to keep users in MCP ecosystem
1022 def processedOutput = output
1023 if (output && !isError) {
1024 try {
1025 // Convert common web URLs to MCP tool suggestions
1026 processedOutput = output.replaceAll(/http:\/\/localhost:8080\/([^\s"'>]+)/) { match ->
1027 def path = match[1]
1028 def toolName = convertWebPathToMcpTool(path)
1029 if (toolName) {
1030 return "MCP_TOOL:${toolName}"
1031 } else {
1032 return match[0] // Keep original if no MCP tool found
1033 }
1034 }
1035
1036 ec.logger.info("MCP Screen Execution: Converted web URLs to MCP tool suggestions for ${screenPath}")
1037 } catch (Exception e) {
1038 ec.logger.warn("MCP Screen Execution: Error converting URLs to MCP tools: ${e.message}")
1039 // Keep original output if conversion fails
1040 }
1041 }
1042
1043 // Return screen result directly as content array (standard MCP flow)
1044 def content = []
1045
1046 // Add execution status as first content item
1047 /*
1048 content << [
1049 type: "text",
1050 text: "Screen execution completed for ${screenPath} in ${executionTime}s"
1051 ]
1052 */
1053
1054 // Add screen HTML as main content 729 // Add screen HTML as main content
730 def content = []
1055 content << [ 731 content << [
1056 type: "text", 732 type: "text",
1057 text: processedOutput, 733 text: output,
1058 screenPath: screenPath, 734 screenPath: screenPath,
1059 screenUrl: screenUrl, 735 screenUrl: screenUrl,
1060 executionTime: executionTime, 736 executionTime: executionTime,
...@@ -1066,7 +742,7 @@ def startTime = System.currentTimeMillis() ...@@ -1066,7 +742,7 @@ def startTime = System.currentTimeMillis()
1066 isError: false 742 isError: false
1067 ] 743 ]
1068 744
1069 ec.logger.info("MCP Screen Execution: Returned result directly for screen ${screenPath} in ${executionTime}s") 745 ec.logger.info("MCP Screen Execution: Returned result for screen ${screenPath} in ${executionTime}s")
1070 ]]></script> 746 ]]></script>
1071 </actions> 747 </actions>
1072 </service> 748 </service>
...@@ -1258,7 +934,7 @@ def startTime = System.currentTimeMillis() ...@@ -1258,7 +934,7 @@ def startTime = System.currentTimeMillis()
1258 <service verb="mcp" noun="BrowseScreens" authenticate="false" allow-remote="true" transaction-timeout="30"> 934 <service verb="mcp" noun="BrowseScreens" authenticate="false" allow-remote="true" transaction-timeout="30">
1259 <description>Browse Moqui screens hierarchically to discover functionality.</description> 935 <description>Browse Moqui screens hierarchically to discover functionality.</description>
1260 <in-parameters> 936 <in-parameters>
1261 <parameter name="path" required="false"><description>Screen path or tool name to browse. Leave empty for root apps.</description></parameter> 937 <parameter name="path" required="false"><description>Screen path to browse (e.g. 'PopCommerce'). Leave empty for root apps.</description></parameter>
1262 <parameter name="sessionId"/> 938 <parameter name="sessionId"/>
1263 </in-parameters> 939 </in-parameters>
1264 <out-parameters> 940 <out-parameters>
...@@ -1267,19 +943,25 @@ def startTime = System.currentTimeMillis() ...@@ -1267,19 +943,25 @@ def startTime = System.currentTimeMillis()
1267 <actions> 943 <actions>
1268 <script><![CDATA[ 944 <script><![CDATA[
1269 import org.moqui.context.ExecutionContext 945 import org.moqui.context.ExecutionContext
1270 import org.moqui.mcp.McpUtils
1271 946
1272 ExecutionContext ec = context.ec 947 ExecutionContext ec = context.ec
1273 def subscreens = [] 948 def subscreens = []
1274 def tools = [] 949 def currentPath = path ?: "root"
1275 def currentPath = path
1276 def userGroups = ec.user.getUserGroupIdSet().collect { it } 950 def userGroups = ec.user.getUserGroupIdSet().collect { it }
1277 951
1278 // Logic to find screens 952 // Helper to convert full component path to simple path (PopCommerce/screen/Root.xml -> PopCommerce.Root)
1279 if (!path || path == "/" || path == "root") { 953 def convertToSimplePath = { fullPath ->
1280 currentPath = "root" 954 if (!fullPath) return null
1281 // Discover top-level applications from ArtifactAuthzCheckView 955 String cleanPath = fullPath
1282 // We look for XML screens that the user has view permission for and are likely app roots 956 if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
957 if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
958 List<String> parts = cleanPath.split('/').toList()
959 if (parts.size() > 1 && parts[1] == "screen") parts.remove(1)
960 return parts.join('.')
961 }
962
963 if (currentPath == "root") {
964 // Discover top-level applications
1283 def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") 965 def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
1284 .condition("userGroupId", userGroups) 966 .condition("userGroupId", userGroups)
1285 .condition("artifactTypeEnumId", "AT_XML_SCREEN") 967 .condition("artifactTypeEnumId", "AT_XML_SCREEN")
...@@ -1295,7 +977,6 @@ def startTime = System.currentTimeMillis() ...@@ -1295,7 +977,6 @@ def startTime = System.currentTimeMillis()
1295 if (parts.length >= 3 && parts[1] == "screen") { 977 if (parts.length >= 3 && parts[1] == "screen") {
1296 def filename = parts[parts.length - 1] 978 def filename = parts[parts.length - 1]
1297 def componentName = parts[0] 979 def componentName = parts[0]
1298 // Match component://Name/screen/Name.xml OR component://Name/screen/NameAdmin.xml OR component://Name/screen/NameRoot.xml
1299 if (filename == componentName + ".xml" || filename == componentName + "Admin.xml" || filename == componentName + "Root.xml" || filename == "webroot.xml") { 980 if (filename == componentName + ".xml" || filename == componentName + "Admin.xml" || filename == componentName + "Root.xml" || filename == "webroot.xml") {
1300 rootScreens.add(name) 981 rootScreens.add(name)
1301 } 982 }
...@@ -1303,109 +984,69 @@ def startTime = System.currentTimeMillis() ...@@ -1303,109 +984,69 @@ def startTime = System.currentTimeMillis()
1303 } 984 }
1304 } 985 }
1305 986
987 /* Removed hardcoded list - rely on proper user permissions and artifact discovery */
988
1306 for (def screenPath in rootScreens) { 989 for (def screenPath in rootScreens) {
1307 def toolName = McpUtils.getToolName(screenPath) 990 def simplePath = convertToSimplePath(screenPath)
1308 if (toolName) {
1309 subscreens << [ 991 subscreens << [
1310 name: toolName, 992 path: simplePath,
1311 path: toolName, 993 description: "Application: ${simplePath}"
1312 description: "Application Root: ${screenPath}"
1313 ] 994 ]
1314 } 995 }
1315 }
1316
1317 // Fallback to basic apps if nothing found (to ensure discoverability)
1318 if (subscreens.isEmpty()) {
1319 ["PopCommerce", "SimpleScreens"].each { root ->
1320 def toolName = "moqui_${root}"
1321 subscreens << [ name: toolName, path: toolName, description: "Application: ${root}" ]
1322 }
1323 }
1324 } else { 996 } else {
1325 // Resolve path 997 // Resolve simple path to component path using longest match and traversal
1326 def screenPath = path.startsWith("moqui_") ? McpUtils.getScreenPath(path) : null 998 def pathParts = currentPath.split('\\.')
1327 // Keep track of the tool name used to reach here, to maintain context 999 def componentName = pathParts[0]
1328 def baseToolName = path.startsWith("moqui_") ? path : null 1000 def baseScreenPath = null
1329 1001 def subParts = []
1330 if (!screenPath && !path.startsWith("component://")) { 1002
1331 // Try to handle "popcommerce/admin" style by guessing 1003 for (int i = pathParts.size(); i >= 1; i--) {
1332 def toolName = "moqui_" + path.replace('/', '_') 1004 def currentTry = "component://${componentName}/screen/" + (i > 1 ? pathParts[1..<i].join('/') : componentName) + ".xml"
1333 screenPath = McpUtils.getScreenPath(toolName) 1005 if (ec.resource.getLocationReference(currentTry).getExists()) {
1334 if (!baseToolName) baseToolName = toolName 1006 baseScreenPath = currentTry
1335 } else if (path.startsWith("component://")) { 1007 if (i < pathParts.size()) subParts = pathParts[i..-1]
1336 screenPath = path 1008 break
1337 // If starting with component path, we can't easily establish a base tool name
1338 // that preserves context if it's not a standard path.
1339 // But McpUtils.getToolName will try.
1340 if (!baseToolName) baseToolName = McpUtils.getToolName(path)
1341 }
1342
1343 if (screenPath) {
1344 def currentScreenPath = screenPath
1345 if (!currentScreenPath.endsWith(".xml")) currentScreenPath += ".xml"
1346 // Check if screen exists, if not try to resolve as subscreen
1347 if (!ec.resource.getLocationReference(currentScreenPath).getExists()) {
1348 def lastSlash = currentScreenPath.lastIndexOf('/')
1349 if (lastSlash > 0) {
1350 def parentPath = currentScreenPath.substring(0, lastSlash) + ".xml"
1351 def subName = currentScreenPath.substring(lastSlash + 1).replace('.xml', '')
1352
1353 if (ec.resource.getLocationReference(parentPath).getExists()) {
1354 try {
1355 def parentDef = ec.screen.getScreenDefinition(parentPath)
1356 def subscreenItem = parentDef?.getSubscreensItem(subName)
1357 if (subscreenItem && subscreenItem.getLocation()) {
1358 ec.logger.info("Redirecting browse from ${currentScreenPath} to ${subscreenItem.getLocation()}")
1359 currentScreenPath = subscreenItem.getLocation()
1360 }
1361 } catch (Exception e) {
1362 ec.logger.warn("Error resolving subscreen location: ${e.message}")
1363 }
1364 }
1365 } 1009 }
1366 } 1010 }
1367 1011
1368 try { 1012 if (!baseScreenPath) {
1369 def currentToolName = baseToolName ?: McpUtils.getToolName(currentScreenPath) 1013 baseScreenPath = "component://${componentName}/screen/${componentName}.xml"
1370 tools << [ 1014 if (pathParts.size() > 1) subParts = pathParts[1..-1]
1371 name: currentToolName, 1015 }
1372 description: "Execute screen: ${currentToolName}",
1373 type: "screen"
1374 ]
1375 1016
1376 // Then look for subscreens
1377 def screenDef = null
1378 try { 1017 try {
1379 screenDef = ec.screen.getScreenDefinition(currentScreenPath) 1018 def screenDef = ec.screen.getScreenDefinition(baseScreenPath)
1380 } catch (Exception e) { 1019 // Traverse subscreens to find the target screen
1381 ec.logger.warn("Error getting screen definition for ${currentScreenPath}: ${e.message}") 1020 for (subName in subParts) {
1021 def subItem = screenDef?.getSubscreensItem(subName)
1022 if (subItem && subItem.getLocation()) {
1023 screenDef = ec.screen.getScreenDefinition(subItem.getLocation())
1024 } else {
1025 // Subscreen not found or defined in-place
1026 break
1027 }
1382 } 1028 }
1383 1029
1384 if (screenDef) { 1030 if (screenDef) {
1385 def subItems = screenDef.getSubscreensItemsSorted() 1031 def subItems = screenDef.getSubscreensItemsSorted()
1386 for (subItem in subItems) { 1032 for (subItem in subItems) {
1387 def subName = subItem.getName() 1033 def subName = subItem.getName()
1388 def parentToolName = baseToolName ?: McpUtils.getToolName(screenPath) 1034 def subPath = currentPath + "." + subName
1389 def subToolName = parentToolName + "_" + subName
1390
1391 subscreens << [ 1035 subscreens << [
1392 name: subToolName, 1036 path: subPath,
1393 path: subToolName,
1394 description: "Subscreen: ${subName}" 1037 description: "Subscreen: ${subName}"
1395 ] 1038 ]
1396 } 1039 }
1397 } 1040 }
1398 } catch (Exception e) { 1041 } catch (Exception e) {
1399 ec.logger.warn("Browse error for ${screenPath}: ${e.message}") 1042 ec.logger.warn("Browse error for ${currentPath}: ${e.message}")
1400 }
1401 } 1043 }
1402 } 1044 }
1403 1045
1404 result = [ 1046 result = [
1405 currentPath: currentPath, 1047 currentPath: currentPath,
1406 subscreens: subscreens, 1048 subscreens: subscreens,
1407 availableTools: tools, 1049 message: "Found ${subscreens.size()} subscreens. Use moqui_render_screen(path='...') to execute."
1408 message: "Found ${subscreens.size()} subscreens and ${tools.size()} tools."
1409 ] 1050 ]
1410 ]]></script> 1051 ]]></script>
1411 </actions> 1052 </actions>
...@@ -1423,13 +1064,22 @@ def startTime = System.currentTimeMillis() ...@@ -1423,13 +1064,22 @@ def startTime = System.currentTimeMillis()
1423 <actions> 1064 <actions>
1424 <script><![CDATA[ 1065 <script><![CDATA[
1425 import org.moqui.context.ExecutionContext 1066 import org.moqui.context.ExecutionContext
1426 import org.moqui.mcp.McpUtils
1427 1067
1428 ExecutionContext ec = context.ec 1068 ExecutionContext ec = context.ec
1429 def matches = [] 1069 def matches = []
1430 1070
1431 // Search all screens known to the system (via authz rules) 1071 // Helper to convert full component path to simple path
1432 // Use disableAuthz() to bypass permission check on the system view itself 1072 def convertToSimplePath = { fullPath ->
1073 if (!fullPath) return null
1074 String cleanPath = fullPath
1075 if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
1076 if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
1077 List<String> parts = cleanPath.split('/').toList()
1078 if (parts.size() > 1 && parts[1] == "screen") parts.remove(1)
1079 return parts.join('.')
1080 }
1081
1082 // Search all screens known to the system
1433 def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") 1083 def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
1434 .condition("artifactTypeEnumId", "AT_XML_SCREEN") 1084 .condition("artifactTypeEnumId", "AT_XML_SCREEN")
1435 .condition("artifactName", "like", "%${query}%") 1085 .condition("artifactName", "like", "%${query}%")
...@@ -1440,10 +1090,10 @@ def startTime = System.currentTimeMillis() ...@@ -1440,10 +1090,10 @@ def startTime = System.currentTimeMillis()
1440 .list() 1090 .list()
1441 1091
1442 for (hit in aacvList) { 1092 for (hit in aacvList) {
1443 def toolName = McpUtils.getToolName(hit.artifactName) 1093 def simplePath = convertToSimplePath(hit.artifactName)
1444 if (toolName) { 1094 if (simplePath) {
1445 matches << [ 1095 matches << [
1446 name: toolName, 1096 path: simplePath,
1447 description: "Screen: ${hit.artifactName}" 1097 description: "Screen: ${hit.artifactName}"
1448 ] 1098 ]
1449 } 1099 }
...@@ -1455,9 +1105,9 @@ def startTime = System.currentTimeMillis() ...@@ -1455,9 +1105,9 @@ def startTime = System.currentTimeMillis()
1455 </service> 1105 </service>
1456 1106
1457 <service verb="mcp" noun="GetScreenDetails" authenticate="false" allow-remote="true" transaction-timeout="30"> 1107 <service verb="mcp" noun="GetScreenDetails" authenticate="false" allow-remote="true" transaction-timeout="30">
1458 <description>Get detailed schema and usage info for a specific screen tool, including inferred parameters from entities.</description> 1108 <description>Get detailed schema and usage info for a specific screen path.</description>
1459 <in-parameters> 1109 <in-parameters>
1460 <parameter name="name" required="true"><description>Tool name (e.g. moqui_PopCommerce_...)</description></parameter> 1110 <parameter name="path" required="true"><description>Screen path (e.g. PopCommerce.Catalog)</description></parameter>
1461 <parameter name="sessionId"/> 1111 <parameter name="sessionId"/>
1462 </in-parameters> 1112 </in-parameters>
1463 <out-parameters> 1113 <out-parameters>
...@@ -1466,60 +1116,84 @@ def startTime = System.currentTimeMillis() ...@@ -1466,60 +1116,84 @@ def startTime = System.currentTimeMillis()
1466 <actions> 1116 <actions>
1467 <script><![CDATA[ 1117 <script><![CDATA[
1468 import org.moqui.context.ExecutionContext 1118 import org.moqui.context.ExecutionContext
1469 import org.moqui.mcp.McpUtils
1470 1119
1471 ExecutionContext ec = context.ec 1120 ExecutionContext ec = context.ec
1472 def decoded = McpUtils.decodeToolName(name, ec) 1121
1473 def screenPath = decoded.screenPath 1122 // Resolve simple path to component path using longest match and traversal
1474 ec.logger.info("GetScreenDetails: name=${name} -> screenPath=${screenPath}") 1123 def pathParts = path.split('\\.')
1124 def componentName = pathParts[0]
1125 def baseScreenPath = null
1126 def subParts = []
1127
1128 for (int i = pathParts.size(); i >= 1; i--) {
1129 def currentTry = "component://${componentName}/screen/" + (i > 1 ? pathParts[1..<i].join('/') : componentName) + ".xml"
1130 if (ec.resource.getLocationReference(currentTry).getExists()) {
1131 baseScreenPath = currentTry
1132 if (i < pathParts.size()) subParts = pathParts[i..-1]
1133 break
1134 }
1135 }
1136
1137 if (!baseScreenPath) {
1138 baseScreenPath = "component://${componentName}/screen/${componentName}.xml"
1139 if (pathParts.size() > 1) subParts = pathParts[1..-1]
1140 }
1141
1475 def toolDef = null 1142 def toolDef = null
1476 1143
1477 if (screenPath) { 1144 if (ec.resource.getLocationReference(baseScreenPath).getExists()) {
1478 try {
1479 def properties = [:]
1480 def required = []
1481 def screenDef = null
1482 try { 1145 try {
1483 screenDef = ec.screen.getScreenDefinition(screenPath) 1146 def screenDef = ec.screen.getScreenDefinition(baseScreenPath)
1484 } catch (Exception e) { 1147
1485 ec.logger.warn("Error getting screen definition for ${screenPath}: ${e.message}") 1148 // Traverse to final subscreen
1149 for (subName in subParts) {
1150 def subItem = screenDef?.getSubscreensItem(subName)
1151 if (subItem && subItem.getLocation()) {
1152 screenDef = ec.screen.getScreenDefinition(subItem.getLocation())
1153 } else {
1154 break
1155 }
1486 } 1156 }
1487 1157
1488 if (screenDef && screenDef.screenNode) { 1158 if (screenDef && screenDef.screenNode) {
1489 // Helper to convert Moqui type to JSON Schema type 1159 def properties = [:]
1160 def required = []
1490 def getJsonType = { moquiType -> 1161 def getJsonType = { moquiType ->
1491 def typeRes = ec.service.sync().name("McpServices.convert#MoquiTypeToJsonSchemaType") 1162 def typeRes = ec.service.sync().name("McpServices.convert#MoquiTypeToJsonSchemaType")
1492 .parameter("moquiType", moquiType).call() 1163 .parameter("moquiType", moquiType).call()
1493 return typeRes?.jsonSchemaType ?: "string" 1164 return typeRes?.jsonSchemaType ?: "string"
1494 } 1165 }
1495 1166
1496 // 1. Extract explicit parameters from screen XML
1497 screenDef.screenNode.children("parameter").each { node ->
1498 def paramName = node.attribute("name")
1499 properties[paramName] = [
1500 type: "string",
1501 description: "Screen Parameter"
1502 ]
1503 if (node.attribute("required") == "true") required << paramName
1504 } 1167 }
1505 1168
1506 // 2. Extract transition parameters (actions/links) 1169 if (screenDef) {
1507 screenDef.screenNode.children("transition").each { node -> 1170 def properties = [:]
1508 node.children("parameter").each { tp -> 1171 def required = []
1509 def tpName = tp.attribute("name") 1172 def getJsonType = { moquiType ->
1510 if (!properties[tpName]) { 1173 def typeRes = ec.service.sync().name("McpServices.convert#MoquiTypeToJsonSchemaType")
1511 properties[tpName] = [ 1174 .parameter("moquiType", moquiType).call()
1512 type: "string", 1175 return typeRes?.jsonSchemaType ?: "string"
1513 description: "Transition Parameter for ${node.attribute('name')}"
1514 ]
1515 if (tp.attribute("required") == "true") required << tpName
1516 } 1176 }
1177
1178 // Extract parameters from screen definition (using protected field access in Groovy)
1179 if (screenDef.parameterByName) {
1180 screenDef.parameterByName.each { name, param ->
1181 properties[name] = [type: "string", description: "Screen Parameter"]
1517 } 1182 }
1518 } 1183 }
1519 1184
1520 // 3. Infer parameters from form-list or form-single if they reference an entity 1185 // Try to get forms and their entities
1521 screenDef.screenNode.depthFirst().findAll { it.name() == "form-single" || it.name() == "form-list" }.each { formNode -> 1186 if (screenDef.formByName) {
1187 screenDef.formByName.each { name, form ->
1188 def formNode = form.internalFormNode
1189 if (!formNode) return
1190
1522 def entityName = formNode.attribute("entity-name") 1191 def entityName = formNode.attribute("entity-name")
1192 if (!entityName) {
1193 def entityFind = formNode.first("entity-find")
1194 if (entityFind) entityName = entityFind.attribute("entity-name")
1195 }
1196
1523 if (entityName && ec.entity.isEntityDefined(entityName)) { 1197 if (entityName && ec.entity.isEntityDefined(entityName)) {
1524 def entityDef = ec.entity.getEntityDefinition(entityName) 1198 def entityDef = ec.entity.getEntityDefinition(entityName)
1525 entityDef.getAllFieldNames().each { fieldName -> 1199 entityDef.getAllFieldNames().each { fieldName ->
...@@ -1527,7 +1201,7 @@ def startTime = System.currentTimeMillis() ...@@ -1527,7 +1201,7 @@ def startTime = System.currentTimeMillis()
1527 def fieldInfo = entityDef.getFieldNode(fieldName) 1201 def fieldInfo = entityDef.getFieldNode(fieldName)
1528 properties[fieldName] = [ 1202 properties[fieldName] = [
1529 type: getJsonType(fieldInfo.attribute("type")), 1203 type: getJsonType(fieldInfo.attribute("type")),
1530 description: "Inferred from entity ${entityName}" 1204 description: "Inferred from entity ${entityName} (form ${name})"
1531 ] 1205 ]
1532 } 1206 }
1533 } 1207 }
...@@ -1536,26 +1210,27 @@ def startTime = System.currentTimeMillis() ...@@ -1536,26 +1210,27 @@ def startTime = System.currentTimeMillis()
1536 } 1210 }
1537 1211
1538 toolDef = [ 1212 toolDef = [
1539 name: name, 1213 path: path,
1540 description: "Full details for ${name} (${screenPath})", 1214 description: "Details for screen ${path}",
1541 inputSchema: [ 1215 inputSchema: [
1542 type: "object", 1216 type: "object",
1543 properties: properties, 1217 properties: properties,
1544 required: required 1218 required: required
1545 ] 1219 ]
1546 ] 1220 ]
1221 }
1547 } catch (Exception e) { 1222 } catch (Exception e) {
1548 ec.logger.warn("Error getting screen details for ${name}: ${e.message}") 1223 ec.logger.warn("Error getting screen details for ${path}: ${e.message}")
1549 } 1224 }
1550 } 1225 }
1551 1226
1552 result = [tool: toolDef] 1227 result = [details: toolDef]
1553 ]]></script> 1228 ]]></script>
1554 </actions> 1229 </actions>
1555 </service> 1230 </service>
1556 1231
1557 <service verb="list" noun="Tools" authenticate="false" allow-remote="true" transaction-timeout="60"> 1232 <service verb="list" noun="Tools" authenticate="false" allow-remote="true" transaction-timeout="60">
1558 <description>List discovery tools and root apps.</description> 1233 <description>List discovery tools and the unified screen renderer.</description>
1559 <in-parameters> 1234 <in-parameters>
1560 <parameter name="sessionId"/> 1235 <parameter name="sessionId"/>
1561 <parameter name="cursor"/> 1236 <parameter name="cursor"/>
...@@ -1569,23 +1244,36 @@ def startTime = System.currentTimeMillis() ...@@ -1569,23 +1244,36 @@ def startTime = System.currentTimeMillis()
1569 1244
1570 ExecutionContext ec = context.ec 1245 ExecutionContext ec = context.ec
1571 1246
1572 // Static list of Discovery Tools
1573 def tools = [ 1247 def tools = [
1574 [ 1248 [
1249 name: "moqui_render_screen",
1250 title: "Render Screen",
1251 description: "Execute and render a Moqui screen. Use discovery tools to find paths.",
1252 inputSchema: [
1253 type: "object",
1254 properties: [
1255 path: [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"],
1256 parameters: [type: "object", description: "Parameters for the screen"],
1257 renderMode: [type: "string", description: "html, text, or json", default: "html"]
1258 ],
1259 required: ["path"]
1260 ]
1261 ],
1262 [
1575 name: "moqui_browse_screens", 1263 name: "moqui_browse_screens",
1576 title: "Browse Screens", 1264 title: "Browse Screens",
1577 description: "Browse the Moqui screen hierarchy. Use this to discover capabilities. Input 'path' (empty for root).", 1265 description: "Browse the Moqui screen hierarchy to discover paths. Input 'path' (empty for root).",
1578 inputSchema: [ 1266 inputSchema: [
1579 type: "object", 1267 type: "object",
1580 properties: [ 1268 properties: [
1581 path: [type: "string", description: "Path to browse (e.g. 'moqui_PopCommerce')"] 1269 path: [type: "string", description: "Path to browse (e.g. 'PopCommerce')"]
1582 ] 1270 ]
1583 ] 1271 ]
1584 ], 1272 ],
1585 [ 1273 [
1586 name: "moqui_search_screens", 1274 name: "moqui_search_screens",
1587 title: "Search Screens", 1275 title: "Search Screens",
1588 description: "Search for screens/functionality by name.", 1276 description: "Search for screens by name to find their paths.",
1589 inputSchema: [ 1277 inputSchema: [
1590 type: "object", 1278 type: "object",
1591 properties: [ 1279 properties: [
...@@ -1597,42 +1285,17 @@ def startTime = System.currentTimeMillis() ...@@ -1597,42 +1285,17 @@ def startTime = System.currentTimeMillis()
1597 [ 1285 [
1598 name: "moqui_get_screen_details", 1286 name: "moqui_get_screen_details",
1599 title: "Get Screen Details", 1287 title: "Get Screen Details",
1600 description: "Get input schema and details for a specific tool/screen.", 1288 description: "Get detailed schema for a specific screen path.",
1601 inputSchema: [ 1289 inputSchema: [
1602 type: "object", 1290 type: "object",
1603 properties: [ 1291 properties: [
1604 name: [type: "string", description: "Tool name"] 1292 path: [type: "string", description: "Screen path"]
1605 ], 1293 ],
1606 required: ["name"] 1294 required: ["path"]
1607 ] 1295 ]
1608 ] 1296 ]
1609 ] 1297 ]
1610 1298
1611 // Add standard MCP methods
1612 def standardMcpMethods = [
1613 [
1614 name: "tools/list",
1615 title: "List Available Tools",
1616 description: "Get a list of all available MCP tools",
1617 inputSchema: [type: "object", properties: [:], required: []]
1618 ],
1619 [
1620 name: "tools/call",
1621 title: "Execute Tool",
1622 description: "Execute a specific MCP tool",
1623 inputSchema: [
1624 type: "object",
1625 properties: [
1626 name: [type: "string"],
1627 arguments: [type: "object"]
1628 ],
1629 required: ["name"]
1630 ]
1631 ]
1632 ]
1633
1634 tools.addAll(standardMcpMethods)
1635
1636 result = [tools: tools] 1299 result = [tools: tools]
1637 ]]></script> 1300 ]]></script>
1638 </actions> 1301 </actions>
......
...@@ -143,12 +143,8 @@ class EnhancedMcpServlet extends HttpServlet { ...@@ -143,12 +143,8 @@ class EnhancedMcpServlet extends HttpServlet {
143 143
144 ExecutionContextImpl ec = ecfi.getEci() 144 ExecutionContextImpl ec = ecfi.getEci()
145 145
146 try { 146 try {
147 // Handle Basic Authentication directly without triggering screen system 147 // Read request body VERY early before any other processing can consume it
148 String authzHeader = request.getHeader("Authorization")
149 boolean authenticated = false
150
151 // Read request body early before any other processing can consume it
152 String requestBody = null 148 String requestBody = null
153 if ("POST".equals(request.getMethod())) { 149 if ("POST".equals(request.getMethod())) {
154 try { 150 try {
...@@ -168,6 +164,17 @@ try { ...@@ -168,6 +164,17 @@ try {
168 } 164 }
169 } 165 }
170 166
167 // Initialize web facade early to set up session and visit context
168 try {
169 ec.initWebFacade(webappName, request, response)
170 } catch (Exception e) {
171 logger.warn("Web facade initialization warning: ${e.message}")
172 }
173
174 // Handle Basic Authentication directly
175 String authzHeader = request.getHeader("Authorization")
176 boolean authenticated = false
177
171 if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith("Basic ")) { 178 if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith("Basic ")) {
172 String basicAuthEncoded = authzHeader.substring(6).trim() 179 String basicAuthEncoded = authzHeader.substring(6).trim()
173 String basicAuthAsString = new String(basicAuthEncoded.decodeBase64()) 180 String basicAuthAsString = new String(basicAuthEncoded.decodeBase64())
...@@ -176,12 +183,15 @@ try { ...@@ -176,12 +183,15 @@ try {
176 String username = basicAuthAsString.substring(0, indexOfColon) 183 String username = basicAuthAsString.substring(0, indexOfColon)
177 String password = basicAuthAsString.substring(indexOfColon + 1) 184 String password = basicAuthAsString.substring(indexOfColon + 1)
178 try { 185 try {
179 logger.info("LOGGING IN ${username} ${password}") 186 logger.info("LOGGING IN ${username}")
180 ec.user.loginUser(username, password) 187 authenticated = ec.user.loginUser(username, password)
181 authenticated = true 188 if (authenticated) {
182 logger.info("Enhanced MCP Basic auth successful for user: ${ec.user?.username}") 189 logger.info("Enhanced MCP Basic auth successful for user: ${ec.user?.username}")
190 } else {
191 logger.warn("Enhanced MCP Basic auth failed for user: ${username}")
192 }
183 } catch (Exception e) { 193 } catch (Exception e) {
184 logger.warn("Enhanced MCP Basic auth failed for user ${username}: ${e.message}") 194 logger.warn("Enhanced MCP Basic auth exception for user ${username}: ${e.message}")
185 } 195 }
186 } else { 196 } else {
187 logger.warn("Enhanced MCP got bad Basic auth credentials string") 197 logger.warn("Enhanced MCP got bad Basic auth credentials string")
...@@ -190,7 +200,7 @@ try { ...@@ -190,7 +200,7 @@ try {
190 200
191 // Re-enabled proper authentication - UserServices compilation issues resolved 201 // Re-enabled proper authentication - UserServices compilation issues resolved
192 if (!authenticated || !ec.user?.userId) { 202 if (!authenticated || !ec.user?.userId) {
193 logger.warn("Enhanced MCP authentication failed - no valid user authenticated") 203 logger.warn("Enhanced MCP authentication failed - authenticated=${authenticated}, userId=${ec.user?.userId}")
194 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) 204 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
195 response.setContentType("application/json") 205 response.setContentType("application/json")
196 response.setHeader("WWW-Authenticate", "Basic realm=\"Moqui MCP\"") 206 response.setHeader("WWW-Authenticate", "Basic realm=\"Moqui MCP\"")
...@@ -202,24 +212,10 @@ try { ...@@ -202,24 +212,10 @@ try {
202 return 212 return
203 } 213 }
204 214
205 // Create Visit for JSON-RPC requests too 215 // Get Visit created by web facade
206 def visit = null 216 def visit = ec.user.getVisit()
207 try {
208 // Initialize web facade for Visit creation
209 ec.initWebFacade(webappName, request, response)
210 // Web facade was successful, get Visit it created
211 visit = ec.user.getVisit()
212 if (!visit) {
213 throw new Exception("Web facade succeeded but no Visit created")
214 }
215 } catch (Exception e) {
216 logger.error("Web facade initialization failed - this is a system configuration error: ${e.message}", e)
217 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "System configuration error: Web facade failed to initialize. Check Moqui logs for details.")
218 return
219 }
220
221 // Final check that we have a Visit
222 if (!visit) { 217 if (!visit) {
218 logger.error("Web facade initialized but no Visit created")
223 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to create Visit") 219 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to create Visit")
224 return 220 return
225 } 221 }
......
...@@ -17,75 +17,7 @@ import org.moqui.context.ExecutionContext ...@@ -17,75 +17,7 @@ import org.moqui.context.ExecutionContext
17 import org.moqui.util.MNode 17 import org.moqui.util.MNode
18 18
19 class McpUtils { 19 class McpUtils {
20 /** 20 // This class is now primarily a placeholder. All tool-name to screen-path
21 * Convert a Moqui screen path to an MCP tool name. 21 // conversion logic has been removed to simplify the system to a single
22 * Format: moqui_<Component>_<Path_Parts> 22 // moqui_render_screen tool.
23 * Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog
24 */
25 static String getToolName(String screenPath) {
26 if (!screenPath) return null
27
28 String cleanPath = screenPath
29 if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
30 if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
31
32 List<String> parts = cleanPath.split('/').toList()
33 if (parts.size() > 1 && parts[1] == "screen") {
34 parts.remove(1)
35 }
36
37 return "moqui_" + parts.join('_')
38 }
39
40 /**
41 * Convert an MCP tool name back to a Moqui screen path.
42 * Assumes standard component://<Component>/screen/<Path>.xml structure.
43 */
44 static String getScreenPath(String toolName) {
45 if (!toolName || !toolName.startsWith("moqui_")) return null
46
47 String cleanName = toolName.substring(6) // Remove moqui_
48 List<String> parts = cleanName.split('_').toList()
49 if (parts.size() < 1) return null
50
51 String component = parts[0]
52 if (parts.size() == 1) {
53 return "component://${component}/screen/${component}.xml"
54 }
55
56 String path = parts.subList(1, parts.size()).join('/')
57 return "component://${component}/screen/${path}.xml"
58 }
59
60 /**
61 * Decodes a tool name into a screen path and potential subscreen path by walking the component structure.
62 * This handles cases where a single XML file contains multiple nested subscreens.
63 */
64 static Map decodeToolName(String toolName, ExecutionContext ec) {
65 if (!toolName || !toolName.startsWith("moqui_")) return [:]
66
67 String cleanName = toolName.substring(6) // Remove moqui_
68 List<String> parts = cleanName.split('_').toList()
69 String component = parts[0]
70
71 String currentPath = "component://${component}/screen"
72 List<String> subNameParts = []
73
74 // Walk down the parts to find where the XML file ends and subscreens begin
75 for (int i = 1; i < parts.size(); i++) {
76 String part = parts[i]
77 String nextPath = "${currentPath}/${part}"
78 if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) {
79 currentPath = nextPath
80 subNameParts = [] // Reset subscreens if we found a deeper file
81 } else {
82 subNameParts << part
83 }
84 }
85
86 return [
87 screenPath: currentPath + ".xml",
88 subscreenName: subNameParts ? subNameParts.join("_") : null
89 ]
90 }
91 } 23 }
......