e18de6c2 by Ean Schuessler

Fix MCP screen discovery closure and JSON-RPC response nesting

- Separate processScreenWithSubscreens closure definition to fix Groovy closure scope issues
- Add proper flattening of subScreenPathList to handle nested collections
- Fix subscreen tool naming with dot notation for parent.child relationships
- Enhance screen tool execution to support subscreen parameters
- Unwrap Moqui service results in EnhancedMcpServlet to avoid double nesting in JSON-RPC responses
- Improve error handling and logging throughout screen discovery process

Now successfully discovers 29 total tools (17 screen tools + 12 service tools) with proper session management.
1 parent 5e5c0f8f
...@@ -407,61 +407,38 @@ ...@@ -407,61 +407,38 @@
407 def isScreenTool = name.startsWith("screen_") 407 def isScreenTool = name.startsWith("screen_")
408 408
409 if (isScreenTool) { 409 if (isScreenTool) {
410 // Decode screen path from tool name (Clean Encoding) 410 // Decode screen path from tool name (Clean Encoding with subscreen support)
411 def toolNameSuffix = name.substring(7) // Remove "screen_" prefix 411 def toolNameSuffix = name.substring(7) // Remove "screen_" prefix
412 412
413 // Restore path: _ -> /, prepend component://, append .xml 413 def screenPath
414 def screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml" 414 def subscreenName = null
415 ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}") 415
416 416 // Check if this is a subscreen (contains dot after the initial prefix)
417 // Restore user context from sessionId before calling screen tool 417 if (toolNameSuffix.contains('.')) {
418 def serviceResult = null 418 // Split on dot to separate parent screen path from subscreen name
419 def visit = null 419 def lastDotIndex = toolNameSuffix.lastIndexOf('.')
420 UserInfo restoredUserInfo = null 420 def parentPath = toolNameSuffix.substring(0, lastDotIndex)
421 try { 421 subscreenName = toolNameSuffix.substring(lastDotIndex + 1)
422 // Get Visit to find the actual user who created this session
423 if (sessionId) {
424 visit = ec.entity.find("moqui.server.Visit")
425 .condition("visitId", sessionId)
426 .disableAuthz()
427 .one()
428
429 if (!visit) {
430 throw new Exception("Invalid session for screen tool execution: ${sessionId}")
431 }
432 } else {
433 // If no sessionId, we're likely in a test/debug scenario, use current user context
434 ec.logger.warn("No sessionId provided for screen tool execution, using current user context")
435 visit = null // Explicitly set to null to indicate no session context
436 }
437
438 // Restore user context - handle special MCP case where Visit was created with ADMIN
439 if (visit && visit.userId && visit.userId != ec.user.userId) {
440 // Restore the actual user who created the session
441 def userAccount = ec.entity.find("moqui.security.UserAccount")
442 .condition("userId", visit.userId)
443 .disableAuthz()
444 .one()
445 if (userAccount) {
446 restoredUserInfo = ec.user.pushUser(userAccount.username)
447 ec.logger.info("Screen tool execution: Restored user context for ${userAccount.username}")
448 }
449 }
450 422
423 // Restore parent path: _ -> /, prepend component://, append .xml
424 screenPath = "component://" + parentPath.replace('_', '/') + ".xml"
425 ec.logger.info("Decoded subscreen path for tool ${name}: parent=${screenPath}, subscreen=${subscreenName}")
426 } else {
427 // Regular screen path: _ -> /, prepend component://, append .xml
428 screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml"
429 ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}")
430 }
431
451 // Now call the screen tool with proper user context 432 // Now call the screen tool with proper user context
452 def screenParams = arguments ?: [:] 433 def screenParams = arguments ?: [:]
434 def serviceCallParams = [screenPath: screenPath, parameters: screenParams, renderMode: "html", sessionId: sessionId]
435 if (subscreenName) {
436 serviceCallParams.subscreenName = subscreenName
437 }
453 serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") 438 serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
454 .parameters([screenPath: screenPath, parameters: screenParams, renderMode: "html", sessionId: sessionId]) 439 .parameters(serviceCallParams)
455 .call() 440 .call()
456 441
457 } finally {
458 // Always restore original user context
459 if (restoredUserInfo != null) {
460 ec.user.popUser()
461 ec.logger.info("Screen tool execution: Restored original user context ${ec.user.username}")
462 }
463 }
464
465 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 442 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
466 443
467 // Convert result to MCP format 444 // Convert result to MCP format
...@@ -476,7 +453,7 @@ ...@@ -476,7 +453,7 @@
476 ] 453 ]
477 } else { 454 } else {
478 content << [ 455 content << [
479 type: "text", 456 type: "html",
480 text: serviceResult.result.toString() ?: "Screen executed successfully" 457 text: serviceResult.result.toString() ?: "Screen executed successfully"
481 ] 458 ]
482 } 459 }
...@@ -525,7 +502,7 @@ ...@@ -525,7 +502,7 @@
525 content: content, 502 content: content,
526 isError: false 503 isError: false
527 ] 504 ]
528 } catch (Exception e) { 505 } catch (Exception e2) {
529 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 506 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
530 507
531 result = [ 508 result = [
...@@ -569,14 +546,6 @@ ...@@ -569,14 +546,6 @@
569 def resources = [] 546 def resources = []
570 547
571 UserInfo adminUserInfo = null 548 UserInfo adminUserInfo = null
572
573 // Update session activity
574 def metadata = [:]
575 try {
576 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
577 } catch (Exception e) {
578 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
579 }
580 549
581 // Store original user context before switching to ADMIN 550 // Store original user context before switching to ADMIN
582 def originalUsername = ec.user.username 551 def originalUsername = ec.user.username
...@@ -999,7 +968,7 @@ try { ...@@ -999,7 +968,7 @@ try {
999 968
1000 def tools = [] 969 def tools = []
1001 970
1002 // Discover screens that user can actually access 971 // Discover screens that user can actually access
1003 def accessibleScreens = [] as Set<String> 972 def accessibleScreens = [] as Set<String>
1004 adminUserInfo = null 973 adminUserInfo = null
1005 try { 974 try {
...@@ -1039,6 +1008,25 @@ try { ...@@ -1039,6 +1008,25 @@ try {
1039 return "screen_" + cleanPath.replace('/', '_') 1008 return "screen_" + cleanPath.replace('/', '_')
1040 } 1009 }
1041 1010
1011 // Helper function to convert screen path to MCP tool name with subscreen support
1012 def screenPathToToolNameWithSubscreens = { screenPath, parentScreenPath = null ->
1013 // If we have a parent screen path, this is a subscreen - use dot notation
1014 if (parentScreenPath) {
1015 def parentCleanPath = parentScreenPath
1016 if (parentCleanPath.startsWith("component://")) parentCleanPath = parentCleanPath.substring(12)
1017 if (parentCleanPath.endsWith(".xml")) parentCleanPath = parentCleanPath.substring(0, parentCleanPath.length() - 4)
1018
1019 // Extract subscreen name from the full screen path
1020 def subscreenName = screenPath.split("/")[-1]
1021 if (subscreenName.endsWith(".xml")) subscreenName = subscreenName.substring(0, subscreenName.length() - 4)
1022
1023 return "screen_" + parentCleanPath.replace('/', '_') + "." + subscreenName
1024 }
1025
1026 // Regular screen path conversion for main screens
1027 return screenPathToToolName(screenPath)
1028 }
1029
1042 // Helper function to create MCP tool from screen 1030 // Helper function to create MCP tool from screen
1043 def createScreenTool = { screenPath, title, description, parameters = [:] -> 1031 def createScreenTool = { screenPath, title, description, parameters = [:] ->
1044 def toolName = screenPathToToolName(screenPath) 1032 def toolName = screenPathToToolName(screenPath)
...@@ -1054,98 +1042,45 @@ try { ...@@ -1054,98 +1042,45 @@ try {
1054 ] 1042 ]
1055 } 1043 }
1056 1044
1057 // Helper function to recursively discover subscreens 1045 // Helper function to recursively process screens and create tools directly
1058 def discoverAllScreens = { baseScreenPath -> 1046 def processScreenWithSubscreens
1059 def allScreens = [] as Set<String> 1047 processScreenWithSubscreens = { screenPath, parentScreenPath = null, processedScreens = null, toolsAccumulator = null ->
1060 def screensToProcess = [baseScreenPath] 1048 ec.logger.info("MCP Screen Discovery: Processing screen ${screenPath} (parent: ${parentScreenPath})")
1061 1049
1062 ec.logger.info("MCP Screen Discovery: Starting discovery for base ${baseScreenPath}") 1050 // Initialize processedScreens and toolsAccumulator if null
1051 if (processedScreens == null) processedScreens = [] as Set<String>
1052 if (toolsAccumulator == null) toolsAccumulator = []
1063 1053
1064 while (screensToProcess) { 1054 if (processedScreens.contains(screenPath)) {
1065 def currentScreenPath = screensToProcess.remove(0) 1055 ec.logger.info("MCP Screen Discovery: Already processed ${screenPath}, skipping")
1066 1056 return
1067 ec.logger.info("MCP Screen Discovery: Processing screen ${currentScreenPath}")
1068
1069 if (allScreens.contains(currentScreenPath)) {
1070 ec.logger.info("MCP Screen Discovery: Already processed ${currentScreenPath}, skipping")
1071 continue
1072 }
1073
1074 allScreens.add(currentScreenPath)
1075
1076 try {
1077 ec.logger.info("MCP Screen Discovery: Getting screen info for ${currentScreenPath}")
1078 // Fix: Use getScreenInfoList and properly access the ScreenInfo object
1079 def screenInfoList = ec.screen.getScreenInfoList(currentScreenPath, 1)
1080 if (screenInfoList && screenInfoList.size() > 0) {
1081 def screenInfo = screenInfoList.first()
1082 ec.logger.info("MCP Screen Discovery: Screen info for ${currentScreenPath}: subscreens=${screenInfo?.subscreenInfoByName?.size() ?: 0}")
1083
1084 // Fix: Use the correct property name 'subscreenInfoByName' (not 'subScreenInfoByName')
1085 if (screenInfo?.subscreenInfoByName) {
1086 ec.logger.info("MCP Screen Discovery: Found subscreens map: ${screenInfo.subscreenInfoByName.keySet()}")
1087 for (subScreenEntry in screenInfo.subscreenInfoByName) {
1088 def subScreenInfo = subScreenEntry.value
1089 def subScreenPath = subScreenInfo?.sd?.location
1090 ec.logger.info("MCP Screen Discovery: Found subscreen ${subScreenPath} for ${currentScreenPath}")
1091 if (subScreenPath && !allScreens.contains(subScreenPath)) {
1092 screensToProcess.add(subScreenPath)
1093 ec.logger.info("MCP Screen Discovery: Added ${subScreenPath} to processing queue")
1094 }
1095 }
1096 } else {
1097 ec.logger.info("MCP Screen Discovery: No subscreens found for ${currentScreenPath}")
1098 }
1099 } else {
1100 ec.logger.info("MCP Screen Discovery: No screen info returned for ${currentScreenPath}")
1101 }
1102 } catch (Exception e) {
1103 ec.logger.info("MCP Screen Discovery: Could not get subscreens for ${currentScreenPath}: ${e.message}")
1104 ec.logger.error("MCP Screen Discovery: Error details:", e)
1105 }
1106 } 1057 }
1107 1058
1108 return allScreens 1059 processedScreens.add(screenPath)
1109 } 1060
1110
1111 // Discover all screens recursively starting from accessible screens
1112 def allAccessibleScreens = [] as Set<String>
1113 ec.logger.info("MCP Screen Discovery: Starting recursive discovery from ${accessibleScreens.size()} base screens")
1114 for (screenPath in accessibleScreens) {
1115 def discoveredScreens = discoverAllScreens(screenPath)
1116 ec.logger.info("MCP Screen Discovery: Found ${discoveredScreens.size()} screens from base ${screenPath}")
1117 allAccessibleScreens.addAll(discoveredScreens)
1118 }
1119 ec.logger.info("MCP Screen Discovery: Recursive discovery found ${allAccessibleScreens.size()} total screens")
1120
1121 // Use recursively discovered screens instead of hardcoded list
1122 for (screenPath in allAccessibleScreens) {
1123 try { 1061 try {
1124 //ec.logger.info("MCP Screen Discovery: Processing screen ${screenPath}") 1062 // Skip problematic patterns early
1125
1126 // For MCP, include all accessible screens - LLMs can decide what's useful
1127 // Skip only obviously problematic patterns
1128 if (screenPath.contains("/error/") || screenPath.contains("/system/")) { 1063 if (screenPath.contains("/error/") || screenPath.contains("/system/")) {
1129 ec.logger.info("MCP Screen Discovery: Skipping system screen ${screenPath}") 1064 ec.logger.info("MCP Screen Discovery: Skipping system screen ${screenPath}")
1130 continue 1065 return
1131 } 1066 }
1132 1067
1133 // Try to get screen definition, but don't require it for MCP 1068 // Determine if this is a subscreen
1069 def isSubscreen = parentScreenPath != null
1070
1071 // Try to get screen definition
1134 def screenDefinition = null 1072 def screenDefinition = null
1135 def title = screenPath.split("/")[-1] 1073 def title = screenPath.split("/")[-1]
1136 def description = "Moqui screen: ${screenPath}" 1074 def description = "Moqui screen: ${screenPath}"
1137 1075
1138 try { 1076 try {
1139 screenDefinition = ec.screen.getScreenDefinition(screenPath) 1077 screenDefinition = ec.screen.getScreenDefinition(screenPath)
1140 // Screen XML doesn't have description elements, so use screen path as description
1141 // We could potentially use default-menu-title attribute if available
1142 if (screenDefinition?.screenNode?.attribute('default-menu-title')) { 1078 if (screenDefinition?.screenNode?.attribute('default-menu-title')) {
1143 title = screenDefinition.screenNode.attribute('default-menu-title') 1079 title = screenDefinition.screenNode.attribute('default-menu-title')
1144 description = "Moqui screen: ${screenPath} (${title})" 1080 description = "Moqui screen: ${screenPath} (${title})"
1145 } 1081 }
1146 } catch (Exception e) { 1082 } catch (Exception e) {
1147 ec.logger.info("MCP Screen Discovery: No screen definition for ${screenPath}, using basic info") 1083 ec.logger.info("MCP Screen Discovery: No screen definition for ${screenPath}, using basic info")
1148 // Continue anyway - the screen might still be useful for MCP
1149 } 1084 }
1150 1085
1151 // Get screen parameters from transitions 1086 // Get screen parameters from transitions
...@@ -1155,19 +1090,19 @@ try { ...@@ -1155,19 +1090,19 @@ try {
1155 if (screenInfo?.transitionInfoByName) { 1090 if (screenInfo?.transitionInfoByName) {
1156 for (transitionEntry in screenInfo.transitionInfoByName) { 1091 for (transitionEntry in screenInfo.transitionInfoByName) {
1157 def transitionInfo = transitionEntry.value 1092 def transitionInfo = transitionEntry.value
1158 // Add path parameters 1093 if (transitionInfo?.ti) {
1159 transitionInfo.ti?.getPathParameterList()?.each { param -> 1094 transitionInfo.ti.getPathParameterList()?.each { param ->
1160 parameters[param] = [ 1095 parameters[param] = [
1161 type: "string", 1096 type: "string",
1162 description: "Path parameter for transition: ${param}" 1097 description: "Path parameter for transition: ${param}"
1163 ] 1098 ]
1164 } 1099 }
1165 // Add request parameters 1100 transitionInfo.ti.getRequestParameterList()?.each { param ->
1166 transitionInfo.ti?.getRequestParameterList()?.each { param -> 1101 parameters[param.name] = [
1167 parameters[param.name] = [ 1102 type: "string",
1168 type: "string", 1103 description: "Request parameter: ${param.name}"
1169 description: "Request parameter: ${param.name}" 1104 ]
1170 ] 1105 }
1171 } 1106 }
1172 } 1107 }
1173 } 1108 }
...@@ -1175,14 +1110,95 @@ try { ...@@ -1175,14 +1110,95 @@ try {
1175 ec.logger.debug("Could not extract parameters from screen ${screenPath}: ${e.message}") 1110 ec.logger.debug("Could not extract parameters from screen ${screenPath}: ${e.message}")
1176 } 1111 }
1177 1112
1178 ec.logger.info("MCP Screen Discovery: Adding accessible screen ${screenPath}") 1113 // Create tool with proper naming
1179 tools << createScreenTool(screenPath, title, description, parameters) 1114 def toolName
1115 if (isSubscreen && parentScreenPath) {
1116 toolName = screenPathToToolNameWithSubscreens(screenPath, parentScreenPath)
1117 ec.logger.info("MCP Screen Discovery: Creating subscreen tool ${toolName} for ${screenPath} (parent: ${parentScreenPath})")
1118 } else {
1119 toolName = screenPathToToolName(screenPath)
1120 ec.logger.info("MCP Screen Discovery: Creating main screen tool ${toolName} for ${screenPath}")
1121 }
1122
1123 def tool = [
1124 name: toolName,
1125 title: title,
1126 description: "Moqui screen: ${screenPath}",
1127 inputSchema: [
1128 type: "object",
1129 properties: parameters,
1130 required: []
1131 ]
1132 ]
1133
1134 ec.logger.info("MCP Screen Discovery: Adding accessible screen tool ${toolName} for ${screenPath}")
1135 toolsAccumulator << tool
1136
1137 // Recursively process subscreens
1138 try {
1139 def screenInfoList = ec.screen.getScreenInfoList(screenPath, 1)
1140 def screenInfo = screenInfoList?.first()
1141 ec.logger.info("MCP Screen Discovery: SCREENINFO ${screenInfo}")
1142 if (screenInfo?.subscreenInfoByName) {
1143 ec.logger.info("MCP Screen Discovery: Found ${screenInfo.subscreenInfoByName.size()} subscreens for ${screenPath}")
1144 for (subScreenEntry in screenInfo.subscreenInfoByName) {
1145 ec.logger.info("MCP Screen Discovery: Process subscreen ${subScreenEntry.key}")
1146 def subScreenInfo = subScreenEntry.value
1147 def subScreenPathList = subScreenInfo?.screenPath
1148 ec.logger.info("MCP Screen Discovery: Processing subscreen entry - key: ${subScreenEntry.key}, location: ${subScreenPathList}")
1149 // Ensure subScreenPathList is a flat list of strings
1150 def flatPathList = []
1151 if (subScreenPathList) {
1152 if (subScreenPathList instanceof Collection) {
1153 for (path in subScreenPathList) {
1154 if (path instanceof Collection) {
1155 flatPathList.addAll(path)
1156 } else {
1157 flatPathList.add(path)
1158 }
1159 }
1160 } else {
1161 flatPathList.add(subScreenPathList)
1162 }
1163 }
1164 for (subScreenPath in flatPathList) {
1165 if (subScreenPath && !processedScreens.contains(subScreenPath)) {
1166 ec.logger.info("MCP Screen Discovery: Processing subscreen path: ${subScreenPath}")
1167 processScreenWithSubscreens(subScreenPath, screenPath, processedScreens, toolsAccumulator)
1168 } else if (!subScreenPath) {
1169 ec.logger.info("MCP Screen Discovery: Subscreen entry ${subScreenEntry.key} has no location, skipping")
1170 } else if (processedScreens.contains(subScreenPath)) {
1171 ec.logger.info("MCP Screen Discovery: Subscreen ${subScreenPath} already processed, skipping")
1172 }
1173 }
1174 }
1175 } else {
1176 ec.logger.info("MCP Screen Discovery: No subscreens found for ${screenPath}")
1177 }
1178 } catch (Exception e) {
1179 ec.logger.info("MCP Screen Discovery: Could not get subscreens for ${screenPath}: ${e.message}")
1180 ec.logger.error("MCP Screen Discovery: Subscreen discovery error details:", e)
1181 }
1180 1182
1181 } catch (Exception e) { 1183 } catch (Exception e) {
1182 ec.logger.warn("Error processing screen ${screenPath}: ${e.message}") 1184 ec.logger.warn("Error processing screen ${screenPath}: ${e.message}")
1183 } 1185 }
1184 } 1186 }
1185 1187
1188 // Process all accessible screens recursively and create tools directly
1189 def processedScreens = [] as Set<String>
1190 ec.logger.info("MCP Screen Discovery: Starting recursive processing from ${accessibleScreens.size()} base screens")
1191 for (screenPath in accessibleScreens) {
1192 ec.logger.info("MCP Screen Discovery: SCREEN PATH ${screenPath}")
1193 processScreenWithSubscreens(screenPath, null, processedScreens, tools)
1194 }
1195 ec.logger.info("MCP Screen Discovery: Recursive processing found ${tools.size()} total tools")
1196
1197 // Note: All screens have already been processed by processScreenWithSubscreens above
1198 // The recursive approach handles both parent screens and their subscreens in a single pass
1199 // No need for additional processing here
1200
1201
1186 ec.logger.info("MCP Screen Discovery: Created ${tools.size()} screen tools for user ${originalUsername}") 1202 ec.logger.info("MCP Screen Discovery: Created ${tools.size()} screen tools for user ${originalUsername}")
1187 1203
1188 result.tools = tools 1204 result.tools = tools
...@@ -1358,6 +1374,7 @@ try { ...@@ -1358,6 +1374,7 @@ try {
1358 <parameter name="parameters" type="Map"><description>Parameters to pass to the screen</description></parameter> 1374 <parameter name="parameters" type="Map"><description>Parameters to pass to the screen</description></parameter>
1359 <parameter name="renderMode" default="html"><description>Render mode: text, html, xml, vuet, qvt</description></parameter> 1375 <parameter name="renderMode" default="html"><description>Render mode: text, html, xml, vuet, qvt</description></parameter>
1360 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> 1376 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter>
1377 <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter>
1361 </in-parameters> 1378 </in-parameters>
1362 <out-parameters> 1379 <out-parameters>
1363 <parameter name="result" type="Map"/> 1380 <parameter name="result" type="Map"/>
...@@ -1397,12 +1414,12 @@ def startTime = System.currentTimeMillis() ...@@ -1397,12 +1414,12 @@ def startTime = System.currentTimeMillis()
1397 def targetScreenDef = null 1414 def targetScreenDef = null
1398 def isStandalone = false 1415 def isStandalone = false
1399 1416
1417 def pathAfterComponent = screenPath.substring(12).replace('.xml','') // Remove "component://"
1418 def pathParts = pathAfterComponent.split("/")
1400 // If the screen path is already a full component:// path, we need to handle it differently 1419 // If the screen path is already a full component:// path, we need to handle it differently
1401 if (screenPath.startsWith("component://")) { 1420 if (screenPath.startsWith("component://")) {
1402 // For component:// paths, we need to use the component's root screen, not webroot 1421 // For component:// paths, we need to use the component's root screen, not webroot
1403 // Extract the component name and use its root screen 1422 // Extract the component name and use its root screen
1404 def pathAfterComponent = screenPath.substring(12) // Remove "component://"
1405 def pathParts = pathAfterComponent.split("/")
1406 if (pathParts.length >= 2) { 1423 if (pathParts.length >= 2) {
1407 def componentName = pathParts[0] 1424 def componentName = pathParts[0]
1408 def remainingPath = pathParts[1..-1].join("/") 1425 def remainingPath = pathParts[1..-1].join("/")
...@@ -1470,8 +1487,8 @@ def startTime = System.currentTimeMillis() ...@@ -1470,8 +1487,8 @@ def startTime = System.currentTimeMillis()
1470 def componentRootScreen = null 1487 def componentRootScreen = null
1471 def possibleRootScreens = [ 1488 def possibleRootScreens = [
1472 "${componentName}.xml", 1489 "${componentName}.xml",
1473 "${componentName}Root.xml", 1490 "${componentName}Admin.xml",
1474 "${componentName}Admin.xml" 1491 "${componentName}Root.xml"
1475 ] 1492 ]
1476 1493
1477 for (rootScreenName in possibleRootScreens) { 1494 for (rootScreenName in possibleRootScreens) {
...@@ -1519,71 +1536,57 @@ def startTime = System.currentTimeMillis() ...@@ -1519,71 +1536,57 @@ def startTime = System.currentTimeMillis()
1519 } 1536 }
1520 } 1537 }
1521 1538
1522 // User context should already be correct from MCP servlet restoration 1539 // Handle subscreen if specified
1523 // CustomScreenTestImpl will capture current user context automatically 1540 if (subscreenName) {
1524 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}") 1541 ec.logger.info("MCP Screen Execution: Handling subscreen ${subscreenName} for parent ${screenPath}")
1525
1526 // Regular screen rendering with current user context - use our custom ScreenTestImpl
1527 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
1528 .rootScreen(rootScreen)
1529 .renderMode(renderMode ? renderMode : "html")
1530 .auth(ec.user.username) // Propagate current user to the test renderer
1531
1532 ec.logger.info("MCP Screen Execution: ScreenTest object created: ${screenTest?.getClass()?.getSimpleName()}")
1533 1542
1534 if (screenTest) { 1543 // For subscreens, we need to modify the render path to include the subscreen
1535 def renderParams = parameters ?: [:] 1544 // The pathParts array already contains the full path, so we need to add the subscreen name
1536 1545 def subscreenPathParts = pathParts + subscreenName.split('_')
1537 // Add current user info to render context to maintain authentication 1546 ec.logger.info("MCP Screen Execution: Full subscreen path parts: ${subscreenPathParts}")
1538 renderParams.userId = ec.user.userId 1547
1539 renderParams.username = ec.user.username 1548 // User context should already be correct from MCP servlet restoration
1540 1549 // CustomScreenTestImpl will capture current user context automatically
1541 // Set user context in ScreenTest to maintain authentication 1550 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}")
1542 // Note: ScreenTestImpl may not have direct userAccountId property, 1551
1543 // the user context should be inherited from the current ExecutionContext 1552 // Regular screen rendering with current user context - use our custom ScreenTestImpl
1544 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}") 1553 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
1545 1554 .rootScreen(rootScreen)
1546 // Regular screen rendering with timeout 1555 .renderMode(renderMode ? renderMode : "html")
1547 def future = java.util.concurrent.Executors.newSingleThreadExecutor().submit({ 1556 .auth(ec.user.username) // Propagate current user to the test renderer
1548 return screenTest.render(testScreenPath, renderParams, null) 1557
1549 } as java.util.concurrent.Callable) 1558 ec.logger.info("MCP Screen Execution: ScreenTest object created for subscreen: ${screenTest?.getClass()?.getSimpleName()}")
1550 1559
1551 try { 1560 if (screenTest) {
1552 def testRender = future.get(30, java.util.concurrent.TimeUnit.SECONDS) // 30 second timeout 1561 def renderParams = parameters ?: [:]
1553 output = testRender.output 1562
1554 def outputLength = output?.length() ?: 0 1563 // Add current user info to render context to maintain authentication
1555 1564 renderParams.userId = ec.user.userId
1556 // SIZE PROTECTION: Check response size before returning 1565 renderParams.username = ec.user.username
1557 def maxResponseSize = 1024 * 1024 // 1MB limit 1566
1558 if (outputLength > maxResponseSize) { 1567 // Set user context in ScreenTest to maintain authentication
1559 isError = true 1568 // Note: ScreenTestImpl may not have direct userAccountId property,
1560 ec.logger.warn("MCP Screen Execution: Response too large for ${screenPath}: ${outputLength} bytes (limit: ${maxResponseSize} bytes)") 1569 // the user context should be inherited from the current ExecutionContext
1561 1570 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}")
1562 // Create truncated response with clear indication 1571 ec.logger.info("SUBSCREEN PATH PARTS ${subscreenPathParts}")
1563 def truncatedOutput = output.substring(0, Math.min(maxResponseSize / 2, outputLength)) 1572
1564 output = """SCREEN RESPONSE TRUNCATED 1573 // Regular screen rendering with timeout for subscreen
1565 1574
1566 The screen '${screenPath}' generated a response that is too large for MCP processing: 1575 try {
1567 - Original size: ${outputLength} bytes 1576 ec.logger.info("TESTRENDER ${subscreenPathParts} ${renderParams}")
1568 - Size limit: ${maxResponseSize} bytes 1577 // For subscreens, the path should be relative to the parent screen that's already set as root
1569 - Truncated to: ${truncatedOutput.length()} bytes 1578 // Since we're using the parent screen as root, we only need the subscreen name part
1570 1579 def testRender = screenTest.render(subscreenName.replaceAll('_','/'), renderParams, "POST")
1571 TRUNCATED CONTENT: 1580 output = testRender.getOutput()
1572 ${truncatedOutput} 1581 def outputLength = output?.length() ?: 0
1573 1582 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
1574 [Response truncated due to size limits. Consider using more specific screen parameters or limiting data ranges.]""" 1583 } catch (java.util.concurrent.TimeoutException e) {
1575 } 1584 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}")
1576 1585 }
1577 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}") 1586 } else {
1578 } catch (java.util.concurrent.TimeoutException e) { 1587 throw new Exception("ScreenTest object is null")
1579 future.cancel(true) 1588 }
1580 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}") 1589 }
1581 } finally {
1582 future.cancel(true)
1583 }
1584 } else {
1585 throw new Exception("ScreenTest object is null")
1586 }
1587 } catch (Exception e) { 1590 } catch (Exception e) {
1588 isError = true 1591 isError = true
1589 ec.logger.warn("MCP Screen Execution: Could not render screen ${screenPath}, exposing error details: ${e.message}") 1592 ec.logger.warn("MCP Screen Execution: Could not render screen ${screenPath}, exposing error details: ${e.message}")
...@@ -1711,7 +1714,7 @@ ${truncatedOutput} ...@@ -1711,7 +1714,7 @@ ${truncatedOutput}
1711 1714
1712 // Return just the rendered screen content for MCP wrapper to handle 1715 // Return just the rendered screen content for MCP wrapper to handle
1713 result = [ 1716 result = [
1714 type: "text", 1717 type: "html",
1715 text: processedOutput, 1718 text: processedOutput,
1716 screenPath: screenPath, 1719 screenPath: screenPath,
1717 screenUrl: screenUrl, 1720 screenUrl: screenUrl,
......
...@@ -150,10 +150,12 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -150,10 +150,12 @@ class CustomScreenTestImpl implements McpScreenTest {
150 void renderAll(List<String> screenPathList, Map<String, Object> parameters, String requestMethod) { 150 void renderAll(List<String> screenPathList, Map<String, Object> parameters, String requestMethod) {
151 // NOTE: using single thread for now, doesn't actually make a lot of difference in overall test run time 151 // NOTE: using single thread for now, doesn't actually make a lot of difference in overall test run time
152 int threads = 1 152 int threads = 1
153 def output
153 if (threads == 1) { 154 if (threads == 1) {
154 for (String screenPath in screenPathList) { 155 for (String screenPath in screenPathList) {
155 McpScreenTestRender str = render(screenPath, parameters, requestMethod) 156 McpScreenTestRender str = render(screenPath, parameters, requestMethod)
156 logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms, ${str.getOutput()?.length()} characters") 157 output = str.getOutput()
158 logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms, ${output?.length()} characters")
157 } 159 }
158 } else { 160 } else {
159 ExecutionContextImpl eci = ecfi.getEci() 161 ExecutionContextImpl eci = ecfi.getEci()
...@@ -264,8 +266,26 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -264,8 +266,26 @@ class CustomScreenTestImpl implements McpScreenTest {
264 long startTime = System.currentTimeMillis() 266 long startTime = System.currentTimeMillis()
265 267
266 // parse the screenPath 268 // parse the screenPath
267 ArrayList<String> screenPathList = ScreenUrlInfo.parseSubScreenPath(csti.rootScreenDef, csti.baseScreenDef, 269 def screenPathList
268 csti.baseScreenPathList, stri.screenPath, stri.parameters, csti.sfi) 270 // Special handling for non-webroot root screens with subscreens
271 if (csti.rootScreenLocation != null && !csti.rootScreenLocation.contains("webroot.xml") && stri.screenPath.contains('/')) {
272 // For non-webroot roots with subscreens, build path list directly
273 // rootScreenDef is the parent screen, screenPath is the subscreen path
274 screenPathList = new ArrayList<>()
275 // Add root screen path (already a full component:// path)
276 screenPathList.add(csti.rootScreenDef.location)
277 // Add subscreen path segments
278 String[] pathSegments = stri.screenPath.split('/')
279 for (String segment in pathSegments) {
280 if (segment && segment.trim().length() > 0) {
281 screenPathList.add(segment)
282 }
283 }
284 logger.info("Custom screen path parsing for non-webroot root: ${screenPathList}")
285 } else {
286 screenPathList = ScreenUrlInfo.parseSubScreenPath(csti.rootScreenDef, csti.baseScreenDef,
287 csti.baseScreenPathList, stri.screenPath, stri.parameters, csti.sfi)
288 }
269 if (screenPathList == null) throw new BaseArtifactException("Could not find screen path ${stri.screenPath} under base screen ${csti.baseScreenDef.location}") 289 if (screenPathList == null) throw new BaseArtifactException("Could not find screen path ${stri.screenPath} under base screen ${csti.baseScreenDef.location}")
270 290
271 // push the context 291 // push the context
...@@ -292,7 +312,7 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -292,7 +312,7 @@ class CustomScreenTestImpl implements McpScreenTest {
292 } 312 }
293 313
294 // set the screenPath 314 // set the screenPath
295 screenRender.screenPath(screenPathList) 315 screenRender.screenPath(screenPathList as java.util.List<String>)
296 316
297 // do the render 317 // do the render
298 try { 318 try {
......
...@@ -862,8 +862,13 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -862,8 +862,13 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
862 logger.error("Enhanced MCP service ${serviceName} returned null result") 862 logger.error("Enhanced MCP service ${serviceName} returned null result")
863 return [error: "Service returned null result"] 863 return [error: "Service returned null result"]
864 } 864 }
865 // Service framework returns result in 'result' field, but also might return the result directly 865 // Service framework returns result in 'result' field when out-parameters are used
866 return result.result ?: result ?: [error: "Service returned invalid result"] 866 // Unwrap the Moqui service result to avoid double nesting in JSON-RPC response
867 if (result?.containsKey('result')) {
868 return result.result ?: [error: "Service returned empty result"]
869 } else {
870 return result ?: [error: "Service returned null result"]
871 }
867 } catch (Exception e) { 872 } catch (Exception e) {
868 logger.error("Error calling Enhanced MCP service ${serviceName}", e) 873 logger.error("Error calling Enhanced MCP service ${serviceName}", e)
869 return [error: e.message] 874 return [error: e.message]
......