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
......
......@@ -14,8 +14,8 @@
package org.moqui.mcp
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import org.moqui.impl.context.ExecutionContextFactoryImpl
import groovy.json.JsonBuilder
import org.moqui.context.ArtifactAuthorizationException
import org.moqui.context.ArtifactTarpitException
import org.moqui.impl.context.ExecutionContextImpl
......@@ -155,11 +155,11 @@ try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
response.setContentType("application/json")
response.setHeader("WWW-Authenticate", "Basic realm=\"Moqui MCP\"")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(new JsonBuilder([
jsonrpc: "2.0",
error: [code: -32003, message: "Authentication required. Use Basic auth with valid Moqui credentials."],
id: null
]))
]).toString())
return
}
......@@ -253,11 +253,11 @@ try {
logger.warn("Enhanced MCP Access Forbidden (no authz): " + e.message)
response.setStatus(HttpServletResponse.SC_FORBIDDEN)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(new JsonBuilder([
jsonrpc: "2.0",
error: [code: -32001, message: "Access Forbidden: " + e.message],
id: null
]))
]).toString())
} catch (ArtifactTarpitException e) {
logger.warn("Enhanced MCP Too Many Requests (tarpit): " + e.message)
response.setStatus(429)
......@@ -265,20 +265,20 @@ try {
response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
}
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(new JsonBuilder([
jsonrpc: "2.0",
error: [code: -32002, message: "Too Many Requests: " + e.message],
id: null
]))
]).toString())
} catch (Throwable t) {
logger.error("Error in Enhanced MCP request", t)
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(new JsonBuilder([
jsonrpc: "2.0",
error: [code: -32603, message: "Internal error: " + t.message],
id: null
]))
]).toString())
} finally {
ec.destroy()
}
......@@ -397,7 +397,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
// Set MCP session ID header per specification BEFORE sending any data
response.setHeader("Mcp-Session-Id", visit.visitId.toString())
sendSseEvent(response.writer, "connect", groovy.json.JsonOutput.toJson(connectData), 0)
sendSseEvent(response.writer, "connect", new JsonBuilder(connectData).toString(), 0)
// Send endpoint info for message posting (for compatibility)
sendSseEvent(response.writer, "endpoint", "/mcp", 1)
......@@ -414,7 +414,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
sessionId: visit.visitId,
architecture: "Visit-based sessions"
]
sendSseEvent(response.writer, "ping", groovy.json.JsonOutput.toJson(pingData), pingCount + 2)
sendSseEvent(response.writer, "ping", new JsonBuilder(pingData).toString(), pingCount + 2)
pingCount++
}
}
......@@ -432,7 +432,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
sessionId: visit.visitId,
timestamp: System.currentTimeMillis()
]
sendSseEvent(response.writer, "disconnect", groovy.json.JsonOutput.toJson(closeData), -1)
sendSseEvent(response.writer, "disconnect", new JsonBuilder(closeData).toString(), -1)
} catch (Exception e) {
// Ignore errors during cleanup
}
......@@ -460,7 +460,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
error: "Missing sessionId parameter or header",
architecture: "Visit-based sessions"
]))
......@@ -477,7 +477,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_NOT_FOUND)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
error: "Session not found: " + sessionId,
architecture: "Visit-based sessions"
]))
......@@ -491,7 +491,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_FORBIDDEN)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
error: "Access denied for session: " + sessionId + " (visit.userId=${visit.userId}, ec.user.userId=${ec.user.userId})",
architecture: "Visit-based sessions"
]))
......@@ -515,7 +515,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32700, message: "Failed to read request body: " + e.message],
id: null
......@@ -528,7 +528,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32602, message: "Empty request body"],
id: null
......@@ -545,7 +545,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32700, message: "Invalid JSON: " + e.message],
id: null
......@@ -558,7 +558,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
id: rpcRequest?.id ?: null
......@@ -576,7 +576,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_OK)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
id: rpcRequest.id,
result: [status: "processed", sessionId: sessionId, architecture: "Visit-based"]
......@@ -587,7 +587,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32603, message: "Internal error: " + e.message],
id: null
......@@ -617,7 +617,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (!"POST".equals(method)) {
response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."],
id: null
......@@ -638,7 +638,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (!"POST".equals(jsonMethod)) {
response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."],
id: null
......@@ -652,7 +652,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (!requestBody) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32602, message: "Empty request body"],
id: null
......@@ -672,7 +672,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
logger.error("Failed to parse JSON-RPC request: ${e.message}")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32700, message: "Invalid JSON: " + e.message],
id: null
......@@ -684,7 +684,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
id: null
......@@ -697,7 +697,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (protocolVersion && protocolVersion != "2025-06-18") {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Unsupported MCP protocol version: ${protocolVersion}. Supported: 2025-06-18"],
id: null
......@@ -713,7 +713,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (!sessionId && rpcRequest.method != "initialize") {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Mcp-Session-Id header required for non-initialize requests"],
id: rpcRequest.id
......@@ -733,7 +733,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (!existingVisit) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Session not found: ${sessionId}"],
id: rpcRequest.id
......@@ -745,7 +745,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
if (!existingVisit.userId || !ec.user.userId || existingVisit.userId.toString() != ec.user.userId.toString()) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Access denied for session: ${sessionId}"],
id: rpcRequest.id
......@@ -761,7 +761,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
logger.error("Error finding session ${sessionId}: ${e.message}")
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32603, message: "Session lookup error: ${e.message}"],
id: rpcRequest.id
......@@ -788,7 +788,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
response.writer.write(JsonOutput.toJson(rpcResponse))
}
private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, String sessionId, def visit) {
......
......@@ -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" }
......