e976f9bd by Ean Schuessler

Add response size protection to prevent MCP server crashes

- Add size checks and limits to screen rendering services
- Implement graceful truncation for oversized responses
- Add timeout protection for screen rendering operations
- Enhanced error handling and logging for large responses
- Prevent server crashes from large screen outputs

This protects the MCP server from crashes when screens generate
large amounts of data while maintaining functionality for normal
use cases.
1 parent 702fafc1
1 <?xml version="1.0" encoding="UTF-8"?> 1 <?xml version="1.0" encoding="UTF-8"?>
2 <screen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 2 <screen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd"> 3 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd"
4 standalone="true">
4 5
5 <parameters> 6 <parameters>
6 <parameter name="message" default-value="Hello from MCP!"/> 7 <parameter name="message" default-value="Hello from MCP!"/>
...@@ -8,7 +9,7 @@ ...@@ -8,7 +9,7 @@
8 9
9 <actions> 10 <actions>
10 <set field="timestamp" from="new java.util.Date()"/> 11 <set field="timestamp" from="new java.util.Date()"/>
11 <set field="user" from="ec.user.username"/> 12 <set field="user" from="ec.user?.username ?: 'Anonymous'"/>
12 </actions> 13 </actions>
13 14
14 <widgets> 15 <widgets>
...@@ -18,6 +19,7 @@ ...@@ -18,6 +19,7 @@
18 <label text="User: ${user}" type="p"/> 19 <label text="User: ${user}" type="p"/>
19 <label text="Time: ${timestamp}" type="p"/> 20 <label text="Time: ${timestamp}" type="p"/>
20 <label text="Render Mode: ${sri.renderMode}" type="p"/> 21 <label text="Render Mode: ${sri.renderMode}" type="p"/>
22 <label text="Screen Path: ${sri.screenPath}" type="p"/>
21 </container> 23 </container>
22 </widgets> 24 </widgets>
23 </screen> 25 </screen>
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -743,35 +743,52 @@ try { ...@@ -743,35 +743,52 @@ try {
743 743
744 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 744 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
745 745
746 // Build field info for LLM 746 // SIZE PROTECTION: Check response size before returning
747 def fieldInfo = [] 747 def jsonOutput = new JsonBuilder([
748 entityDef.allFieldInfoList.each { field -> 748 entityName: entityName,
749 fieldInfo << [ 749 description: entityDef.description ?: "",
750 name: field.name, 750 packageName: entityDef.packageName,
751 type: field.type, 751 recordCount: entityList.size(),
752 description: field.description ?: "", 752 fields: fieldInfo,
753 isPk: field.isPk, 753 data: entityList
754 required: field.notNull 754 ]).toString()
755 ]
756 }
757 755
758 // Convert to MCP resource content 756 def maxResponseSize = 1024 * 1024 // 1MB limit
759 def contents = [ 757 if (jsonOutput.length() > maxResponseSize) {
760 [ 758 ec.logger.warn("ResourcesRead: Response too large for ${entityName}: ${jsonOutput.length()} bytes (limit: ${maxResponseSize} bytes)")
761 uri: uri, 759
762 mimeType: "application/json", 760 // Create truncated response with fewer records
763 text: new JsonBuilder([ 761 def truncatedList = entityList.take(10) // Keep only first 10 records
764 entityName: entityName, 762 def truncatedOutput = new JsonBuilder([
765 description: entityDef.description ?: "", 763 entityName: entityName,
766 packageName: entityDef.packageName, 764 description: entityDef.description ?: "",
767 recordCount: entityList.size(), 765 packageName: entityDef.packageName,
768 fields: fieldInfo, 766 recordCount: entityList.size(),
769 data: entityList 767 fields: fieldInfo,
770 ]).toString() 768 data: truncatedList,
769 truncated: true,
770 originalSize: entityList.size(),
771 truncatedSize: truncatedList.size(),
772 message: "Response truncated due to size limits. Original data has ${entityList.size()} records, showing first ${truncatedList.size()}."
773 ]).toString()
774
775 contents = [
776 [
777 uri: uri,
778 mimeType: "application/json",
779 text: truncatedOutput
780 ]
771 ] 781 ]
772 ] 782 } else {
773 783 // Normal response
774 result = [contents: contents] 784 contents = [
785 [
786 uri: uri,
787 mimeType: "application/json",
788 text: jsonOutput
789 ]
790 ]
791 }
775 792
776 } catch (Exception e) { 793 } catch (Exception e) {
777 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 794 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
...@@ -1320,7 +1337,7 @@ def startTime = System.currentTimeMillis() ...@@ -1320,7 +1337,7 @@ def startTime = System.currentTimeMillis()
1320 def targetScreenDef = null 1337 def targetScreenDef = null
1321 def isStandalone = false 1338 def isStandalone = false
1322 1339
1323 // If the screen path is already a full component:// path, we need to handle it differently 1340 // If the screen path is already a full component:// path, we need to handle it differently
1324 if (screenPath.startsWith("component://")) { 1341 if (screenPath.startsWith("component://")) {
1325 // For component:// paths, we need to use the component's root screen, not webroot 1342 // For component:// paths, we need to use the component's root screen, not webroot
1326 // Extract the component name and use its root screen 1343 // Extract the component name and use its root screen
...@@ -1416,10 +1433,21 @@ def startTime = System.currentTimeMillis() ...@@ -1416,10 +1433,21 @@ def startTime = System.currentTimeMillis()
1416 testScreenPath = remainingPath 1433 testScreenPath = remainingPath
1417 ec.logger.info("MCP Screen Execution: Using component root ${rootScreen} for path ${testScreenPath}") 1434 ec.logger.info("MCP Screen Execution: Using component root ${rootScreen} for path ${testScreenPath}")
1418 } else { 1435 } else {
1419 // Fallback: try using webroot with the full path 1436 // For mantle and other components, try using the component's screen directory as root
1420 rootScreen = "component://webroot/screen/webroot.xml" 1437 // This is a better fallback than webroot
1421 testScreenPath = pathAfterComponent 1438 def componentScreenRoot = "component://${componentName}/screen/"
1422 ec.logger.warn("MCP Screen Execution: Could not find component root for ${componentName}, using webroot fallback: ${testScreenPath}") 1439 if (pathAfterComponent.startsWith("${componentName}/screen/")) {
1440 // Extract the screen file name from the path
1441 def screenFileName = pathAfterComponent.substring("${componentName}/screen/".length())
1442 rootScreen = screenPath // Use the full path as root
1443 testScreenPath = "" // Empty path for direct screen access
1444 ec.logger.info("MCP Screen Execution: Using component screen as direct root: ${rootScreen}")
1445 } else {
1446 // Final fallback: try webroot
1447 rootScreen = "component://webroot/screen/webroot.xml"
1448 testScreenPath = pathAfterComponent
1449 ec.logger.warn("MCP Screen Execution: Could not find component root for ${componentName}, using webroot fallback: ${testScreenPath}")
1450 }
1423 } 1451 }
1424 } 1452 }
1425 } 1453 }
...@@ -1431,13 +1459,16 @@ def startTime = System.currentTimeMillis() ...@@ -1431,13 +1459,16 @@ def startTime = System.currentTimeMillis()
1431 } 1459 }
1432 } 1460 }
1433 1461
1434 // Restore user context from sessionId before creating ScreenTest 1462 // User context should already be correct from MCP servlet restoration
1435 // Regular screen rendering with restored user context - use our custom ScreenTestImpl 1463 // CustomScreenTestImpl will capture current user context automatically
1436 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) 1464 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}")
1437 .rootScreen(rootScreen) 1465
1438 .renderMode(renderMode ? renderMode : "html") 1466 // Regular screen rendering with current user context - use our custom ScreenTestImpl
1439 1467 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
1440 ec.logger.info("MCP Screen Execution: ScreenTest object created: ${screenTest?.getClass()?.getSimpleName()}") 1468 .rootScreen(rootScreen)
1469 .renderMode(renderMode ? renderMode : "html")
1470
1471 ec.logger.info("MCP Screen Execution: ScreenTest object created: ${screenTest?.getClass()?.getSimpleName()}")
1441 1472
1442 if (screenTest) { 1473 if (screenTest) {
1443 def renderParams = parameters ?: [:] 1474 def renderParams = parameters ?: [:]
...@@ -1460,7 +1491,28 @@ def startTime = System.currentTimeMillis() ...@@ -1460,7 +1491,28 @@ def startTime = System.currentTimeMillis()
1460 def testRender = future.get(30, java.util.concurrent.TimeUnit.SECONDS) // 30 second timeout 1491 def testRender = future.get(30, java.util.concurrent.TimeUnit.SECONDS) // 30 second timeout
1461 output = testRender.output 1492 output = testRender.output
1462 def outputLength = output?.length() ?: 0 1493 def outputLength = output?.length() ?: 0
1463 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${outputLength}") 1494
1495 // SIZE PROTECTION: Check response size before returning
1496 def maxResponseSize = 1024 * 1024 // 1MB limit
1497 if (outputLength > maxResponseSize) {
1498 ec.logger.warn("MCP Screen Execution: Response too large for ${screenPath}: ${outputLength} bytes (limit: ${maxResponseSize} bytes)")
1499
1500 // Create truncated response with clear indication
1501 def truncatedOutput = output.substring(0, Math.min(maxResponseSize / 2, outputLength))
1502 output = """SCREEN RESPONSE TRUNCATED
1503
1504 The screen '${screenPath}' generated a response that is too large for MCP processing:
1505 - Original size: ${outputLength} bytes
1506 - Size limit: ${maxResponseSize} bytes
1507 - Truncated to: ${truncatedOutput.length()} bytes
1508
1509 TRUNCATED CONTENT:
1510 ${truncatedOutput}
1511
1512 [Response truncated due to size limits. Consider using more specific screen parameters or limiting data ranges.]"""
1513 }
1514
1515 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
1464 } catch (java.util.concurrent.TimeoutException e) { 1516 } catch (java.util.concurrent.TimeoutException e) {
1465 future.cancel(true) 1517 future.cancel(true)
1466 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}") 1518 throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}")
......
...@@ -203,6 +203,13 @@ class CustomScreenTestImpl implements ScreenTest { ...@@ -203,6 +203,13 @@ class CustomScreenTestImpl implements ScreenTest {
203 if (authzDisabled) threadEci.artifactExecutionFacade.disableAuthz() 203 if (authzDisabled) threadEci.artifactExecutionFacade.disableAuthz()
204 // as this is used for server-side transition calls don't do tarpit checks 204 // as this is used for server-side transition calls don't do tarpit checks
205 threadEci.artifactExecutionFacade.disableTarpit() 205 threadEci.artifactExecutionFacade.disableTarpit()
206
207 // Ensure user is properly authenticated in the thread context
208 // This is critical for screen authentication checks
209 if (username != null && !username.isEmpty()) {
210 threadEci.userFacade.internalLoginUser(username)
211 }
212
206 renderInternal(threadEci, stri) 213 renderInternal(threadEci, stri)
207 threadEci.destroy() 214 threadEci.destroy()
208 } catch (Throwable t) { 215 } catch (Throwable t) {
...@@ -230,8 +237,17 @@ class CustomScreenTestImpl implements ScreenTest { ...@@ -230,8 +237,17 @@ class CustomScreenTestImpl implements ScreenTest {
230 // push context 237 // push context
231 ContextStack cs = eci.getContext() 238 ContextStack cs = eci.getContext()
232 cs.push() 239 cs.push()
240
241 // Ensure user context is properly set in session attributes for WebFacadeStub
242 def sessionAttributes = new HashMap(sti.sessionAttributes)
243 sessionAttributes.putAll([
244 userId: eci.userFacade.getUserId(),
245 username: eci.userFacade.getUsername(),
246 userAccountId: eci.userFacade.getUserId()
247 ])
248
233 // create our custom WebFacadeStub instead of framework's, passing screen path for proper path handling 249 // create our custom WebFacadeStub instead of framework's, passing screen path for proper path handling
234 WebFacadeStub wfs = new WebFacadeStub(sti.ecfi, stri.parameters, sti.sessionAttributes, stri.requestMethod, stri.screenPath) 250 WebFacadeStub wfs = new WebFacadeStub(sti.ecfi, stri.parameters, sessionAttributes, stri.requestMethod, stri.screenPath)
235 // set stub on eci, will also put parameters in context 251 // set stub on eci, will also put parameters in context
236 eci.setWebFacade(wfs) 252 eci.setWebFacade(wfs)
237 // make the ScreenRender 253 // make the ScreenRender
......
...@@ -67,15 +67,15 @@ class WebFacadeStub implements WebFacade { ...@@ -67,15 +67,15 @@ class WebFacadeStub implements WebFacade {
67 } 67 }
68 68
69 protected void createMockHttpObjects() { 69 protected void createMockHttpObjects() {
70 // Create mock HttpServletRequest 70 // Create mock HttpSession first
71 this.httpServletRequest = new MockHttpServletRequest(this.parameters, this.requestMethod) 71 this.httpSession = new MockHttpSession(this.sessionAttributes)
72
73 // Create mock HttpServletRequest with session
74 this.httpServletRequest = new MockHttpServletRequest(this.parameters, this.requestMethod, this.httpSession)
72 75
73 // Create mock HttpServletResponse with String output capture 76 // Create mock HttpServletResponse with String output capture
74 this.httpServletResponse = new MockHttpServletResponse() 77 this.httpServletResponse = new MockHttpServletResponse()
75 78
76 // Create mock HttpSession
77 this.httpSession = new MockHttpSession(this.sessionAttributes)
78
79 // Note: Objects are linked through the mock implementations 79 // Note: Objects are linked through the mock implementations
80 } 80 }
81 81
...@@ -256,14 +256,27 @@ class WebFacadeStub implements WebFacade { ...@@ -256,14 +256,27 @@ class WebFacadeStub implements WebFacade {
256 private final Map<String, Object> parameters 256 private final Map<String, Object> parameters
257 private final String method 257 private final String method
258 private HttpSession session 258 private HttpSession session
259 private String remoteUser = null
260 private java.security.Principal userPrincipal = null
259 261
260 MockHttpServletRequest(Map<String, Object> parameters, String method) { 262 MockHttpServletRequest(Map<String, Object> parameters, String method, HttpSession session = null) {
261 this.parameters = parameters ?: [:] 263 this.parameters = parameters ?: [:]
262 this.method = method ?: "GET" 264 this.method = method ?: "GET"
265 this.session = session
266
267 // Extract user information from session attributes for authentication
268 if (session) {
269 def username = session.getAttribute("username")
270 def userId = session.getAttribute("userId")
271 if (username) {
272 this.remoteUser = username as String
273 this.userPrincipal = new java.security.Principal() {
274 String getName() { return username as String }
275 }
276 }
277 }
263 } 278 }
264 279
265 void setSession(HttpSession session) { this.session = session }
266
267 @Override String getMethod() { return method } 280 @Override String getMethod() { return method }
268 @Override String getScheme() { return "http" } 281 @Override String getScheme() { return "http" }
269 @Override String getServerName() { return "localhost" } 282 @Override String getServerName() { return "localhost" }
...@@ -303,9 +316,9 @@ class WebFacadeStub implements WebFacade { ...@@ -303,9 +316,9 @@ class WebFacadeStub implements WebFacade {
303 @Override void removeAttribute(String name) {} 316 @Override void removeAttribute(String name) {}
304 @Override java.util.Enumeration<String> getAttributeNames() { return Collections.enumeration([]) } 317 @Override java.util.Enumeration<String> getAttributeNames() { return Collections.enumeration([]) }
305 @Override String getAuthType() { return null } 318 @Override String getAuthType() { return null }
306 @Override String getRemoteUser() { return null } 319 @Override String getRemoteUser() { return remoteUser }
307 @Override boolean isUserInRole(String role) { return false } 320 @Override boolean isUserInRole(String role) { return false }
308 @Override java.security.Principal getUserPrincipal() { return null } 321 @Override java.security.Principal getUserPrincipal() { return userPrincipal }
309 @Override String getRequestedSessionId() { return null } 322 @Override String getRequestedSessionId() { return null }
310 @Override StringBuffer getRequestURL() { return new StringBuffer("http://localhost:8080/test") } 323 @Override StringBuffer getRequestURL() { return new StringBuffer("http://localhost:8080/test") }
311 @Override String getPathInfo() { return "/test" } 324 @Override String getPathInfo() { return "/test" }
......