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
<?xml version="1.0" encoding="UTF-8"?>
<screen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd">
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd"
standalone="true">
<parameters>
<parameter name="message" default-value="Hello from MCP!"/>
......@@ -8,7 +9,7 @@
<actions>
<set field="timestamp" from="new java.util.Date()"/>
<set field="user" from="ec.user.username"/>
<set field="user" from="ec.user?.username ?: 'Anonymous'"/>
</actions>
<widgets>
......@@ -18,6 +19,7 @@
<label text="User: ${user}" type="p"/>
<label text="Time: ${timestamp}" type="p"/>
<label text="Render Mode: ${sri.renderMode}" type="p"/>
<label text="Screen Path: ${sri.screenPath}" type="p"/>
</container>
</widgets>
</screen>
\ No newline at end of file
......
......@@ -743,24 +743,8 @@ try {
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Build field info for LLM
def fieldInfo = []
entityDef.allFieldInfoList.each { field ->
fieldInfo << [
name: field.name,
type: field.type,
description: field.description ?: "",
isPk: field.isPk,
required: field.notNull
]
}
// Convert to MCP resource content
def contents = [
[
uri: uri,
mimeType: "application/json",
text: new JsonBuilder([
// SIZE PROTECTION: Check response size before returning
def jsonOutput = new JsonBuilder([
entityName: entityName,
description: entityDef.description ?: "",
packageName: entityDef.packageName,
......@@ -768,10 +752,43 @@ try {
fields: fieldInfo,
data: entityList
]).toString()
def maxResponseSize = 1024 * 1024 // 1MB limit
if (jsonOutput.length() > maxResponseSize) {
ec.logger.warn("ResourcesRead: Response too large for ${entityName}: ${jsonOutput.length()} bytes (limit: ${maxResponseSize} bytes)")
// Create truncated response with fewer records
def truncatedList = entityList.take(10) // Keep only first 10 records
def truncatedOutput = new JsonBuilder([
entityName: entityName,
description: entityDef.description ?: "",
packageName: entityDef.packageName,
recordCount: entityList.size(),
fields: fieldInfo,
data: truncatedList,
truncated: true,
originalSize: entityList.size(),
truncatedSize: truncatedList.size(),
message: "Response truncated due to size limits. Original data has ${entityList.size()} records, showing first ${truncatedList.size()}."
]).toString()
contents = [
[
uri: uri,
mimeType: "application/json",
text: truncatedOutput
]
]
result = [contents: contents]
} else {
// Normal response
contents = [
[
uri: uri,
mimeType: "application/json",
text: jsonOutput
]
]
}
} catch (Exception e) {
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
......@@ -1416,7 +1433,17 @@ def startTime = System.currentTimeMillis()
testScreenPath = remainingPath
ec.logger.info("MCP Screen Execution: Using component root ${rootScreen} for path ${testScreenPath}")
} else {
// Fallback: try using webroot with the full path
// For mantle and other components, try using the component's screen directory as root
// This is a better fallback than webroot
def componentScreenRoot = "component://${componentName}/screen/"
if (pathAfterComponent.startsWith("${componentName}/screen/")) {
// Extract the screen file name from the path
def screenFileName = pathAfterComponent.substring("${componentName}/screen/".length())
rootScreen = screenPath // Use the full path as root
testScreenPath = "" // Empty path for direct screen access
ec.logger.info("MCP Screen Execution: Using component screen as direct root: ${rootScreen}")
} else {
// Final fallback: try webroot
rootScreen = "component://webroot/screen/webroot.xml"
testScreenPath = pathAfterComponent
ec.logger.warn("MCP Screen Execution: Could not find component root for ${componentName}, using webroot fallback: ${testScreenPath}")
......@@ -1424,6 +1451,7 @@ def startTime = System.currentTimeMillis()
}
}
}
}
} else {
// Fallback for malformed component paths
testScreenPath = pathAfterComponent
......@@ -1431,8 +1459,11 @@ def startTime = System.currentTimeMillis()
}
}
// Restore user context from sessionId before creating ScreenTest
// Regular screen rendering with restored user context - use our custom ScreenTestImpl
// User context should already be correct from MCP servlet restoration
// CustomScreenTestImpl will capture current user context automatically
ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}")
// Regular screen rendering with current user context - use our custom ScreenTestImpl
def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
.rootScreen(rootScreen)
.renderMode(renderMode ? renderMode : "html")
......@@ -1460,7 +1491,28 @@ def startTime = System.currentTimeMillis()
def testRender = future.get(30, java.util.concurrent.TimeUnit.SECONDS) // 30 second timeout
output = testRender.output
def outputLength = output?.length() ?: 0
ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${outputLength}")
// SIZE PROTECTION: Check response size before returning
def maxResponseSize = 1024 * 1024 // 1MB limit
if (outputLength > maxResponseSize) {
ec.logger.warn("MCP Screen Execution: Response too large for ${screenPath}: ${outputLength} bytes (limit: ${maxResponseSize} bytes)")
// Create truncated response with clear indication
def truncatedOutput = output.substring(0, Math.min(maxResponseSize / 2, outputLength))
output = """SCREEN RESPONSE TRUNCATED
The screen '${screenPath}' generated a response that is too large for MCP processing:
- Original size: ${outputLength} bytes
- Size limit: ${maxResponseSize} bytes
- Truncated to: ${truncatedOutput.length()} bytes
TRUNCATED CONTENT:
${truncatedOutput}
[Response truncated due to size limits. Consider using more specific screen parameters or limiting data ranges.]"""
}
ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
} catch (java.util.concurrent.TimeoutException e) {
future.cancel(true)
throw new Exception("Screen rendering timed out after 30 seconds for ${screenPath}")
......
......@@ -203,6 +203,13 @@ class CustomScreenTestImpl implements ScreenTest {
if (authzDisabled) threadEci.artifactExecutionFacade.disableAuthz()
// as this is used for server-side transition calls don't do tarpit checks
threadEci.artifactExecutionFacade.disableTarpit()
// Ensure user is properly authenticated in the thread context
// This is critical for screen authentication checks
if (username != null && !username.isEmpty()) {
threadEci.userFacade.internalLoginUser(username)
}
renderInternal(threadEci, stri)
threadEci.destroy()
} catch (Throwable t) {
......@@ -230,8 +237,17 @@ class CustomScreenTestImpl implements ScreenTest {
// push context
ContextStack cs = eci.getContext()
cs.push()
// Ensure user context is properly set in session attributes for WebFacadeStub
def sessionAttributes = new HashMap(sti.sessionAttributes)
sessionAttributes.putAll([
userId: eci.userFacade.getUserId(),
username: eci.userFacade.getUsername(),
userAccountId: eci.userFacade.getUserId()
])
// create our custom WebFacadeStub instead of framework's, passing screen path for proper path handling
WebFacadeStub wfs = new WebFacadeStub(sti.ecfi, stri.parameters, sti.sessionAttributes, stri.requestMethod, stri.screenPath)
WebFacadeStub wfs = new WebFacadeStub(sti.ecfi, stri.parameters, sessionAttributes, stri.requestMethod, stri.screenPath)
// set stub on eci, will also put parameters in context
eci.setWebFacade(wfs)
// make the ScreenRender
......
......@@ -67,15 +67,15 @@ class WebFacadeStub implements WebFacade {
}
protected void createMockHttpObjects() {
// Create mock HttpServletRequest
this.httpServletRequest = new MockHttpServletRequest(this.parameters, this.requestMethod)
// Create mock HttpSession first
this.httpSession = new MockHttpSession(this.sessionAttributes)
// Create mock HttpServletRequest with session
this.httpServletRequest = new MockHttpServletRequest(this.parameters, this.requestMethod, this.httpSession)
// Create mock HttpServletResponse with String output capture
this.httpServletResponse = new MockHttpServletResponse()
// Create mock HttpSession
this.httpSession = new MockHttpSession(this.sessionAttributes)
// Note: Objects are linked through the mock implementations
}
......@@ -256,13 +256,26 @@ class WebFacadeStub implements WebFacade {
private final Map<String, Object> parameters
private final String method
private HttpSession session
private String remoteUser = null
private java.security.Principal userPrincipal = null
MockHttpServletRequest(Map<String, Object> parameters, String method) {
MockHttpServletRequest(Map<String, Object> parameters, String method, HttpSession session = null) {
this.parameters = parameters ?: [:]
this.method = method ?: "GET"
}
this.session = session
void setSession(HttpSession session) { this.session = session }
// Extract user information from session attributes for authentication
if (session) {
def username = session.getAttribute("username")
def userId = session.getAttribute("userId")
if (username) {
this.remoteUser = username as String
this.userPrincipal = new java.security.Principal() {
String getName() { return username as String }
}
}
}
}
@Override String getMethod() { return method }
@Override String getScheme() { return "http" }
......@@ -303,9 +316,9 @@ class WebFacadeStub implements WebFacade {
@Override void removeAttribute(String name) {}
@Override java.util.Enumeration<String> getAttributeNames() { return Collections.enumeration([]) }
@Override String getAuthType() { return null }
@Override String getRemoteUser() { return null }
@Override String getRemoteUser() { return remoteUser }
@Override boolean isUserInRole(String role) { return false }
@Override java.security.Principal getUserPrincipal() { return null }
@Override java.security.Principal getUserPrincipal() { return userPrincipal }
@Override String getRequestedSessionId() { return null }
@Override StringBuffer getRequestURL() { return new StringBuffer("http://localhost:8080/test") }
@Override String getPathInfo() { return "/test" }
......