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,24 +743,8 @@ try { ...@@ -743,24 +743,8 @@ 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 ->
749 fieldInfo << [
750 name: field.name,
751 type: field.type,
752 description: field.description ?: "",
753 isPk: field.isPk,
754 required: field.notNull
755 ]
756 }
757
758 // Convert to MCP resource content
759 def contents = [
760 [
761 uri: uri,
762 mimeType: "application/json",
763 text: new JsonBuilder([
764 entityName: entityName, 748 entityName: entityName,
765 description: entityDef.description ?: "", 749 description: entityDef.description ?: "",
766 packageName: entityDef.packageName, 750 packageName: entityDef.packageName,
...@@ -768,10 +752,43 @@ try { ...@@ -768,10 +752,43 @@ try {
768 fields: fieldInfo, 752 fields: fieldInfo,
769 data: entityList 753 data: entityList
770 ]).toString() 754 ]).toString()
755
756 def maxResponseSize = 1024 * 1024 // 1MB limit
757 if (jsonOutput.length() > maxResponseSize) {
758 ec.logger.warn("ResourcesRead: Response too large for ${entityName}: ${jsonOutput.length()} bytes (limit: ${maxResponseSize} bytes)")
759
760 // Create truncated response with fewer records
761 def truncatedList = entityList.take(10) // Keep only first 10 records
762 def truncatedOutput = new JsonBuilder([
763 entityName: entityName,
764 description: entityDef.description ?: "",
765 packageName: entityDef.packageName,
766 recordCount: entityList.size(),
767 fields: fieldInfo,
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
771 ] 780 ]
772 ] 781 ]
773 782 } else {
774 result = [contents: contents] 783 // Normal response
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
...@@ -1416,7 +1433,17 @@ def startTime = System.currentTimeMillis() ...@@ -1416,7 +1433,17 @@ 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
1437 // This is a better fallback than webroot
1438 def componentScreenRoot = "component://${componentName}/screen/"
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
1420 rootScreen = "component://webroot/screen/webroot.xml" 1447 rootScreen = "component://webroot/screen/webroot.xml"
1421 testScreenPath = pathAfterComponent 1448 testScreenPath = pathAfterComponent
1422 ec.logger.warn("MCP Screen Execution: Could not find component root for ${componentName}, using webroot fallback: ${testScreenPath}") 1449 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() ...@@ -1424,6 +1451,7 @@ def startTime = System.currentTimeMillis()
1424 } 1451 }
1425 } 1452 }
1426 } 1453 }
1454 }
1427 } else { 1455 } else {
1428 // Fallback for malformed component paths 1456 // Fallback for malformed component paths
1429 testScreenPath = pathAfterComponent 1457 testScreenPath = pathAfterComponent
...@@ -1431,8 +1459,11 @@ def startTime = System.currentTimeMillis() ...@@ -1431,8 +1459,11 @@ 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
1464 ec.logger.info("MCP Screen Execution: Current user context - userId: ${ec.user.userId}, username: ${ec.user.username}")
1465
1466 // Regular screen rendering with current user context - use our custom ScreenTestImpl
1436 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) 1467 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
1437 .rootScreen(rootScreen) 1468 .rootScreen(rootScreen)
1438 .renderMode(renderMode ? renderMode : "html") 1469 .renderMode(renderMode ? renderMode : "html")
...@@ -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
......
...@@ -14,8 +14,8 @@ ...@@ -14,8 +14,8 @@
14 package org.moqui.mcp 14 package org.moqui.mcp
15 15
16 import groovy.json.JsonSlurper 16 import groovy.json.JsonSlurper
17 import groovy.json.JsonOutput
18 import org.moqui.impl.context.ExecutionContextFactoryImpl 17 import org.moqui.impl.context.ExecutionContextFactoryImpl
18 import groovy.json.JsonBuilder
19 import org.moqui.context.ArtifactAuthorizationException 19 import org.moqui.context.ArtifactAuthorizationException
20 import org.moqui.context.ArtifactTarpitException 20 import org.moqui.context.ArtifactTarpitException
21 import org.moqui.impl.context.ExecutionContextImpl 21 import org.moqui.impl.context.ExecutionContextImpl
...@@ -155,11 +155,11 @@ try { ...@@ -155,11 +155,11 @@ try {
155 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) 155 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
156 response.setContentType("application/json") 156 response.setContentType("application/json")
157 response.setHeader("WWW-Authenticate", "Basic realm=\"Moqui MCP\"") 157 response.setHeader("WWW-Authenticate", "Basic realm=\"Moqui MCP\"")
158 response.writer.write(groovy.json.JsonOutput.toJson([ 158 response.writer.write(new JsonBuilder([
159 jsonrpc: "2.0", 159 jsonrpc: "2.0",
160 error: [code: -32003, message: "Authentication required. Use Basic auth with valid Moqui credentials."], 160 error: [code: -32003, message: "Authentication required. Use Basic auth with valid Moqui credentials."],
161 id: null 161 id: null
162 ])) 162 ]).toString())
163 return 163 return
164 } 164 }
165 165
...@@ -253,11 +253,11 @@ try { ...@@ -253,11 +253,11 @@ try {
253 logger.warn("Enhanced MCP Access Forbidden (no authz): " + e.message) 253 logger.warn("Enhanced MCP Access Forbidden (no authz): " + e.message)
254 response.setStatus(HttpServletResponse.SC_FORBIDDEN) 254 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
255 response.setContentType("application/json") 255 response.setContentType("application/json")
256 response.writer.write(groovy.json.JsonOutput.toJson([ 256 response.writer.write(new JsonBuilder([
257 jsonrpc: "2.0", 257 jsonrpc: "2.0",
258 error: [code: -32001, message: "Access Forbidden: " + e.message], 258 error: [code: -32001, message: "Access Forbidden: " + e.message],
259 id: null 259 id: null
260 ])) 260 ]).toString())
261 } catch (ArtifactTarpitException e) { 261 } catch (ArtifactTarpitException e) {
262 logger.warn("Enhanced MCP Too Many Requests (tarpit): " + e.message) 262 logger.warn("Enhanced MCP Too Many Requests (tarpit): " + e.message)
263 response.setStatus(429) 263 response.setStatus(429)
...@@ -265,20 +265,20 @@ try { ...@@ -265,20 +265,20 @@ try {
265 response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) 265 response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
266 } 266 }
267 response.setContentType("application/json") 267 response.setContentType("application/json")
268 response.writer.write(groovy.json.JsonOutput.toJson([ 268 response.writer.write(new JsonBuilder([
269 jsonrpc: "2.0", 269 jsonrpc: "2.0",
270 error: [code: -32002, message: "Too Many Requests: " + e.message], 270 error: [code: -32002, message: "Too Many Requests: " + e.message],
271 id: null 271 id: null
272 ])) 272 ]).toString())
273 } catch (Throwable t) { 273 } catch (Throwable t) {
274 logger.error("Error in Enhanced MCP request", t) 274 logger.error("Error in Enhanced MCP request", t)
275 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) 275 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
276 response.setContentType("application/json") 276 response.setContentType("application/json")
277 response.writer.write(groovy.json.JsonOutput.toJson([ 277 response.writer.write(new JsonBuilder([
278 jsonrpc: "2.0", 278 jsonrpc: "2.0",
279 error: [code: -32603, message: "Internal error: " + t.message], 279 error: [code: -32603, message: "Internal error: " + t.message],
280 id: null 280 id: null
281 ])) 281 ]).toString())
282 } finally { 282 } finally {
283 ec.destroy() 283 ec.destroy()
284 } 284 }
...@@ -397,7 +397,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -397,7 +397,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
397 // Set MCP session ID header per specification BEFORE sending any data 397 // Set MCP session ID header per specification BEFORE sending any data
398 response.setHeader("Mcp-Session-Id", visit.visitId.toString()) 398 response.setHeader("Mcp-Session-Id", visit.visitId.toString())
399 399
400 sendSseEvent(response.writer, "connect", groovy.json.JsonOutput.toJson(connectData), 0) 400 sendSseEvent(response.writer, "connect", new JsonBuilder(connectData).toString(), 0)
401 401
402 // Send endpoint info for message posting (for compatibility) 402 // Send endpoint info for message posting (for compatibility)
403 sendSseEvent(response.writer, "endpoint", "/mcp", 1) 403 sendSseEvent(response.writer, "endpoint", "/mcp", 1)
...@@ -414,7 +414,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -414,7 +414,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
414 sessionId: visit.visitId, 414 sessionId: visit.visitId,
415 architecture: "Visit-based sessions" 415 architecture: "Visit-based sessions"
416 ] 416 ]
417 sendSseEvent(response.writer, "ping", groovy.json.JsonOutput.toJson(pingData), pingCount + 2) 417 sendSseEvent(response.writer, "ping", new JsonBuilder(pingData).toString(), pingCount + 2)
418 pingCount++ 418 pingCount++
419 } 419 }
420 } 420 }
...@@ -432,7 +432,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -432,7 +432,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
432 sessionId: visit.visitId, 432 sessionId: visit.visitId,
433 timestamp: System.currentTimeMillis() 433 timestamp: System.currentTimeMillis()
434 ] 434 ]
435 sendSseEvent(response.writer, "disconnect", groovy.json.JsonOutput.toJson(closeData), -1) 435 sendSseEvent(response.writer, "disconnect", new JsonBuilder(closeData).toString(), -1)
436 } catch (Exception e) { 436 } catch (Exception e) {
437 // Ignore errors during cleanup 437 // Ignore errors during cleanup
438 } 438 }
...@@ -460,7 +460,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -460,7 +460,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
460 response.setContentType("application/json") 460 response.setContentType("application/json")
461 response.setCharacterEncoding("UTF-8") 461 response.setCharacterEncoding("UTF-8")
462 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 462 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
463 response.writer.write(groovy.json.JsonOutput.toJson([ 463 response.writer.write(JsonOutput.toJson([
464 error: "Missing sessionId parameter or header", 464 error: "Missing sessionId parameter or header",
465 architecture: "Visit-based sessions" 465 architecture: "Visit-based sessions"
466 ])) 466 ]))
...@@ -477,7 +477,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -477,7 +477,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
477 response.setContentType("application/json") 477 response.setContentType("application/json")
478 response.setCharacterEncoding("UTF-8") 478 response.setCharacterEncoding("UTF-8")
479 response.setStatus(HttpServletResponse.SC_NOT_FOUND) 479 response.setStatus(HttpServletResponse.SC_NOT_FOUND)
480 response.writer.write(groovy.json.JsonOutput.toJson([ 480 response.writer.write(JsonOutput.toJson([
481 error: "Session not found: " + sessionId, 481 error: "Session not found: " + sessionId,
482 architecture: "Visit-based sessions" 482 architecture: "Visit-based sessions"
483 ])) 483 ]))
...@@ -491,7 +491,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -491,7 +491,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
491 response.setContentType("application/json") 491 response.setContentType("application/json")
492 response.setCharacterEncoding("UTF-8") 492 response.setCharacterEncoding("UTF-8")
493 response.setStatus(HttpServletResponse.SC_FORBIDDEN) 493 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
494 response.writer.write(groovy.json.JsonOutput.toJson([ 494 response.writer.write(JsonOutput.toJson([
495 error: "Access denied for session: " + sessionId + " (visit.userId=${visit.userId}, ec.user.userId=${ec.user.userId})", 495 error: "Access denied for session: " + sessionId + " (visit.userId=${visit.userId}, ec.user.userId=${ec.user.userId})",
496 architecture: "Visit-based sessions" 496 architecture: "Visit-based sessions"
497 ])) 497 ]))
...@@ -515,7 +515,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -515,7 +515,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
515 response.setContentType("application/json") 515 response.setContentType("application/json")
516 response.setCharacterEncoding("UTF-8") 516 response.setCharacterEncoding("UTF-8")
517 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 517 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
518 response.writer.write(groovy.json.JsonOutput.toJson([ 518 response.writer.write(JsonOutput.toJson([
519 jsonrpc: "2.0", 519 jsonrpc: "2.0",
520 error: [code: -32700, message: "Failed to read request body: " + e.message], 520 error: [code: -32700, message: "Failed to read request body: " + e.message],
521 id: null 521 id: null
...@@ -528,7 +528,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -528,7 +528,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
528 response.setContentType("application/json") 528 response.setContentType("application/json")
529 response.setCharacterEncoding("UTF-8") 529 response.setCharacterEncoding("UTF-8")
530 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 530 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
531 response.writer.write(groovy.json.JsonOutput.toJson([ 531 response.writer.write(JsonOutput.toJson([
532 jsonrpc: "2.0", 532 jsonrpc: "2.0",
533 error: [code: -32602, message: "Empty request body"], 533 error: [code: -32602, message: "Empty request body"],
534 id: null 534 id: null
...@@ -545,7 +545,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -545,7 +545,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
545 response.setContentType("application/json") 545 response.setContentType("application/json")
546 response.setCharacterEncoding("UTF-8") 546 response.setCharacterEncoding("UTF-8")
547 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 547 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
548 response.writer.write(groovy.json.JsonOutput.toJson([ 548 response.writer.write(JsonOutput.toJson([
549 jsonrpc: "2.0", 549 jsonrpc: "2.0",
550 error: [code: -32700, message: "Invalid JSON: " + e.message], 550 error: [code: -32700, message: "Invalid JSON: " + e.message],
551 id: null 551 id: null
...@@ -558,7 +558,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -558,7 +558,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
558 response.setContentType("application/json") 558 response.setContentType("application/json")
559 response.setCharacterEncoding("UTF-8") 559 response.setCharacterEncoding("UTF-8")
560 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 560 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
561 response.writer.write(groovy.json.JsonOutput.toJson([ 561 response.writer.write(JsonOutput.toJson([
562 jsonrpc: "2.0", 562 jsonrpc: "2.0",
563 error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"], 563 error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
564 id: rpcRequest?.id ?: null 564 id: rpcRequest?.id ?: null
...@@ -576,7 +576,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -576,7 +576,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
576 response.setContentType("application/json") 576 response.setContentType("application/json")
577 response.setCharacterEncoding("UTF-8") 577 response.setCharacterEncoding("UTF-8")
578 response.setStatus(HttpServletResponse.SC_OK) 578 response.setStatus(HttpServletResponse.SC_OK)
579 response.writer.write(groovy.json.JsonOutput.toJson([ 579 response.writer.write(JsonOutput.toJson([
580 jsonrpc: "2.0", 580 jsonrpc: "2.0",
581 id: rpcRequest.id, 581 id: rpcRequest.id,
582 result: [status: "processed", sessionId: sessionId, architecture: "Visit-based"] 582 result: [status: "processed", sessionId: sessionId, architecture: "Visit-based"]
...@@ -587,7 +587,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -587,7 +587,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
587 response.setContentType("application/json") 587 response.setContentType("application/json")
588 response.setCharacterEncoding("UTF-8") 588 response.setCharacterEncoding("UTF-8")
589 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) 589 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
590 response.writer.write(groovy.json.JsonOutput.toJson([ 590 response.writer.write(JsonOutput.toJson([
591 jsonrpc: "2.0", 591 jsonrpc: "2.0",
592 error: [code: -32603, message: "Internal error: " + e.message], 592 error: [code: -32603, message: "Internal error: " + e.message],
593 id: null 593 id: null
...@@ -617,7 +617,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -617,7 +617,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
617 if (!"POST".equals(method)) { 617 if (!"POST".equals(method)) {
618 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) 618 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
619 response.setContentType("application/json") 619 response.setContentType("application/json")
620 response.writer.write(groovy.json.JsonOutput.toJson([ 620 response.writer.write(JsonOutput.toJson([
621 jsonrpc: "2.0", 621 jsonrpc: "2.0",
622 error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."], 622 error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."],
623 id: null 623 id: null
...@@ -638,7 +638,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -638,7 +638,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
638 if (!"POST".equals(jsonMethod)) { 638 if (!"POST".equals(jsonMethod)) {
639 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) 639 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
640 response.setContentType("application/json") 640 response.setContentType("application/json")
641 response.writer.write(groovy.json.JsonOutput.toJson([ 641 response.writer.write(JsonOutput.toJson([
642 jsonrpc: "2.0", 642 jsonrpc: "2.0",
643 error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."], 643 error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."],
644 id: null 644 id: null
...@@ -652,7 +652,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -652,7 +652,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
652 if (!requestBody) { 652 if (!requestBody) {
653 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 653 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
654 response.setContentType("application/json") 654 response.setContentType("application/json")
655 response.writer.write(groovy.json.JsonOutput.toJson([ 655 response.writer.write(JsonOutput.toJson([
656 jsonrpc: "2.0", 656 jsonrpc: "2.0",
657 error: [code: -32602, message: "Empty request body"], 657 error: [code: -32602, message: "Empty request body"],
658 id: null 658 id: null
...@@ -672,7 +672,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -672,7 +672,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
672 logger.error("Failed to parse JSON-RPC request: ${e.message}") 672 logger.error("Failed to parse JSON-RPC request: ${e.message}")
673 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 673 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
674 response.setContentType("application/json") 674 response.setContentType("application/json")
675 response.writer.write(groovy.json.JsonOutput.toJson([ 675 response.writer.write(JsonOutput.toJson([
676 jsonrpc: "2.0", 676 jsonrpc: "2.0",
677 error: [code: -32700, message: "Invalid JSON: " + e.message], 677 error: [code: -32700, message: "Invalid JSON: " + e.message],
678 id: null 678 id: null
...@@ -684,7 +684,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -684,7 +684,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
684 if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) { 684 if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
685 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 685 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
686 response.setContentType("application/json") 686 response.setContentType("application/json")
687 response.writer.write(groovy.json.JsonOutput.toJson([ 687 response.writer.write(JsonOutput.toJson([
688 jsonrpc: "2.0", 688 jsonrpc: "2.0",
689 error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"], 689 error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
690 id: null 690 id: null
...@@ -697,7 +697,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -697,7 +697,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
697 if (protocolVersion && protocolVersion != "2025-06-18") { 697 if (protocolVersion && protocolVersion != "2025-06-18") {
698 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 698 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
699 response.setContentType("application/json") 699 response.setContentType("application/json")
700 response.writer.write(groovy.json.JsonOutput.toJson([ 700 response.writer.write(JsonOutput.toJson([
701 jsonrpc: "2.0", 701 jsonrpc: "2.0",
702 error: [code: -32600, message: "Unsupported MCP protocol version: ${protocolVersion}. Supported: 2025-06-18"], 702 error: [code: -32600, message: "Unsupported MCP protocol version: ${protocolVersion}. Supported: 2025-06-18"],
703 id: null 703 id: null
...@@ -713,7 +713,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -713,7 +713,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
713 if (!sessionId && rpcRequest.method != "initialize") { 713 if (!sessionId && rpcRequest.method != "initialize") {
714 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 714 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
715 response.setContentType("application/json") 715 response.setContentType("application/json")
716 response.writer.write(groovy.json.JsonOutput.toJson([ 716 response.writer.write(JsonOutput.toJson([
717 jsonrpc: "2.0", 717 jsonrpc: "2.0",
718 error: [code: -32600, message: "Mcp-Session-Id header required for non-initialize requests"], 718 error: [code: -32600, message: "Mcp-Session-Id header required for non-initialize requests"],
719 id: rpcRequest.id 719 id: rpcRequest.id
...@@ -733,7 +733,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -733,7 +733,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
733 if (!existingVisit) { 733 if (!existingVisit) {
734 response.setStatus(HttpServletResponse.SC_NOT_FOUND) 734 response.setStatus(HttpServletResponse.SC_NOT_FOUND)
735 response.setContentType("application/json") 735 response.setContentType("application/json")
736 response.writer.write(groovy.json.JsonOutput.toJson([ 736 response.writer.write(JsonOutput.toJson([
737 jsonrpc: "2.0", 737 jsonrpc: "2.0",
738 error: [code: -32600, message: "Session not found: ${sessionId}"], 738 error: [code: -32600, message: "Session not found: ${sessionId}"],
739 id: rpcRequest.id 739 id: rpcRequest.id
...@@ -745,7 +745,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -745,7 +745,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
745 if (!existingVisit.userId || !ec.user.userId || existingVisit.userId.toString() != ec.user.userId.toString()) { 745 if (!existingVisit.userId || !ec.user.userId || existingVisit.userId.toString() != ec.user.userId.toString()) {
746 response.setStatus(HttpServletResponse.SC_FORBIDDEN) 746 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
747 response.setContentType("application/json") 747 response.setContentType("application/json")
748 response.writer.write(groovy.json.JsonOutput.toJson([ 748 response.writer.write(JsonOutput.toJson([
749 jsonrpc: "2.0", 749 jsonrpc: "2.0",
750 error: [code: -32600, message: "Access denied for session: ${sessionId}"], 750 error: [code: -32600, message: "Access denied for session: ${sessionId}"],
751 id: rpcRequest.id 751 id: rpcRequest.id
...@@ -761,7 +761,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -761,7 +761,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
761 logger.error("Error finding session ${sessionId}: ${e.message}") 761 logger.error("Error finding session ${sessionId}: ${e.message}")
762 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) 762 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
763 response.setContentType("application/json") 763 response.setContentType("application/json")
764 response.writer.write(groovy.json.JsonOutput.toJson([ 764 response.writer.write(JsonOutput.toJson([
765 jsonrpc: "2.0", 765 jsonrpc: "2.0",
766 error: [code: -32603, message: "Session lookup error: ${e.message}"], 766 error: [code: -32603, message: "Session lookup error: ${e.message}"],
767 id: rpcRequest.id 767 id: rpcRequest.id
...@@ -788,7 +788,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -788,7 +788,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
788 response.setContentType("application/json") 788 response.setContentType("application/json")
789 response.setCharacterEncoding("UTF-8") 789 response.setCharacterEncoding("UTF-8")
790 790
791 response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse)) 791 response.writer.write(JsonOutput.toJson(rpcResponse))
792 } 792 }
793 793
794 private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, String sessionId, def visit) { 794 private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, String sessionId, def visit) {
......
...@@ -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,13 +256,26 @@ class WebFacadeStub implements WebFacade { ...@@ -256,13 +256,26 @@ 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"
263 } 265 this.session = session
264 266
265 void setSession(HttpSession session) { this.session = session } 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 }
278 }
266 279
267 @Override String getMethod() { return method } 280 @Override String getMethod() { return method }
268 @Override String getScheme() { return "http" } 281 @Override String getScheme() { return "http" }
...@@ -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" }
......