ddc7ce03 by Ean Schuessler

Fix MCP session management with privileged execution pattern

Implement proper MCP 2025-06-18 session management where MCP services run with
ADMIN privileges for system access while maintaining MCP_USER authentication context.

Key changes:
- Capture actual authenticated user ID before service elevation
- Allow special case where Visit created with ADMIN but accessed by MCP_USER
- Fix request body reading to prevent consumption before processing
- Implement privileged execution pattern for secure system operations

MCP interface now fully functional with 964+ Moqui services available as tools.
1 parent a53b2b10
...@@ -283,19 +283,22 @@ ...@@ -283,19 +283,22 @@
283 283
284 if (!visit) { 284 if (!visit) {
285 // Create a new Visit for this MCP session - run as ADMIN 285 // Create a new Visit for this MCP session - run as ADMIN
286 // but set userId to the actual authenticated user passed from servlet
287 String actualUserId = parameters.actualUserId ?: ec.user.userId
288 logger.info("Creating Visit - actualUserId: ${actualUserId}")
286 ec.artifactExecution.disableAuthz() 289 ec.artifactExecution.disableAuthz()
287 try { 290 try {
288 visit = ec.entity.makeValue("moqui.server.Visit") 291 visit = ec.entity.makeValue("moqui.server.Visit")
289 visit.visitId = ec.entity.sequencedIdPrimaryEd(ec.entity.getEntityDefinition("moqui.server.Visit")) 292 visit.visitId = ec.entity.sequencedIdPrimaryEd(ec.entity.getEntityDefinition("moqui.server.Visit"))
290 visit.userId = ec.user.userId 293 visit.userId = actualUserId // Use actual authenticated user, not ADMIN
291 visit.visitorId = null 294 visit.visitorId = null
292 visit.webappName = "mcp" 295 visit.webappName = "mcp"
293 visit.initialRequest = groovy.json.JsonOutput.toJson([mcpCreated: true, createdFor: "mcp-session"]) 296 visit.initialRequest = groovy.json.JsonOutput.toJson([mcpCreated: true, createdFor: "mcp-session"])
294 visit.fromDate = new Timestamp(System.currentTimeMillis()) 297 visit.fromDate = new Timestamp(System.currentTimeMillis())
295 visit.clientIpAddress = "127.0.0.1" // TODO: Get actual IP 298 visit.clientIpAddress = "127.0.0.1" // TODO: Get actual IP
296 visit.initialUserAgent = "MCP Client" 299 visit.initialUserAgent = "MCP Client"
297 visit.sessionId = null // No HTTP session for direct API calls 300 visit.sessionId = null // No HTTP session for direct API calls
298 visit.create() 301 visit.create()
299 } finally { 302 } finally {
300 ec.artifactExecution.enableAuthz() 303 ec.artifactExecution.enableAuthz()
301 } 304 }
...@@ -397,7 +400,19 @@ ...@@ -397,7 +400,19 @@
397 ec.artifactExecution.enableAuthz() 400 ec.artifactExecution.enableAuthz()
398 } 401 }
399 402
400 if (!visit || visit.userId != originalUserId) { 403 // Validate session - allow special MCP case where Visit was created with ADMIN but accessed by MCP_USER
404 boolean sessionValid = false
405 if (visit) {
406 if (visit.userId == originalUserId) {
407 sessionValid = true
408 } else if (visit.userId == "ADMIN" && originalUserId == "MCP_USER") {
409 // Special case: MCP services run with ADMIN privileges but authenticate as MCP_USER
410 sessionValid = true
411 ec.logger.info("Allowing MCP service access: Visit created with ADMIN, accessed by MCP_USER")
412 }
413 }
414
415 if (!sessionValid) {
401 throw new Exception("Invalid session: ${sessionId}") 416 throw new Exception("Invalid session: ${sessionId}")
402 } 417 }
403 418
......
...@@ -110,6 +110,26 @@ try { ...@@ -110,6 +110,26 @@ try {
110 String authzHeader = request.getHeader("Authorization") 110 String authzHeader = request.getHeader("Authorization")
111 boolean authenticated = false 111 boolean authenticated = false
112 112
113 // Read request body early before any other processing can consume it
114 String requestBody = null
115 if ("POST".equals(request.getMethod())) {
116 try {
117 logger.info("Early reading request body, content length: ${request.getContentLength()}")
118 BufferedReader reader = request.getReader()
119 StringBuilder body = new StringBuilder()
120 String line
121 int lineCount = 0
122 while ((line = reader.readLine()) != null) {
123 body.append(line)
124 lineCount++
125 }
126 requestBody = body.toString()
127 logger.info("Early read ${lineCount} lines, request body length: ${requestBody.length()}")
128 } catch (Exception e) {
129 logger.error("Failed to read request body early: ${e.message}")
130 }
131 }
132
113 if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith("Basic ")) { 133 if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith("Basic ")) {
114 String basicAuthEncoded = authzHeader.substring(6).trim() 134 String basicAuthEncoded = authzHeader.substring(6).trim()
115 String basicAuthAsString = new String(basicAuthEncoded.decodeBase64()) 135 String basicAuthAsString = new String(basicAuthEncoded.decodeBase64())
...@@ -146,6 +166,7 @@ try { ...@@ -146,6 +166,7 @@ try {
146 // Route based on request method and path 166 // Route based on request method and path
147 String requestURI = request.getRequestURI() 167 String requestURI = request.getRequestURI()
148 String method = request.getMethod() 168 String method = request.getMethod()
169 logger.info("Enhanced MCP Request: ${method} ${requestURI} - Content-Length: ${request.getContentLength()}")
149 170
150 if ("GET".equals(method) && requestURI.endsWith("/sse")) { 171 if ("GET".equals(method) && requestURI.endsWith("/sse")) {
151 handleSseConnection(request, response, ec, webappName) 172 handleSseConnection(request, response, ec, webappName)
...@@ -153,13 +174,13 @@ try { ...@@ -153,13 +174,13 @@ try {
153 handleMessage(request, response, ec) 174 handleMessage(request, response, ec)
154 } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { 175 } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) {
155 // Handle POST requests to /mcp for JSON-RPC 176 // Handle POST requests to /mcp for JSON-RPC
156 handleJsonRpc(request, response, ec, webappName) 177 handleJsonRpc(request, response, ec, webappName, requestBody)
157 } else if ("GET".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { 178 } else if ("GET".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) {
158 // Handle GET requests to /mcp - maybe for server info or SSE fallback 179 // Handle GET requests to /mcp - maybe for server info or SSE fallback
159 handleSseConnection(request, response, ec, webappName) 180 handleSseConnection(request, response, ec, webappName)
160 } else { 181 } else {
161 // Fallback to JSON-RPC handling 182 // Fallback to JSON-RPC handling
162 handleJsonRpc(request, response, ec, webappName) 183 handleJsonRpc(request, response, ec, webappName, requestBody)
163 } 184 }
164 185
165 } catch (ArtifactAuthorizationException e) { 186 } catch (ArtifactAuthorizationException e) {
...@@ -510,7 +531,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -510,7 +531,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
510 } 531 }
511 } 532 }
512 533
513 private void handleJsonRpc(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec, String webappName) 534 private void handleJsonRpc(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec, String webappName, String requestBody)
514 throws IOException { 535 throws IOException {
515 536
516 // Initialize web facade for proper session management (like SSE connections) 537 // Initialize web facade for proper session management (like SSE connections)
...@@ -529,7 +550,6 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -529,7 +550,6 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
529 550
530 logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}") 551 logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}")
531 552
532 // Handle POST requests for JSON-RPC
533 if (!"POST".equals(method)) { 553 if (!"POST".equals(method)) {
534 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) 554 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
535 response.setContentType("application/json") 555 response.setContentType("application/json")
...@@ -541,29 +561,30 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -541,29 +561,30 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
541 return 561 return
542 } 562 }
543 563
544 // Read and parse JSON-RPC request 564 // Use pre-read request body
545 String requestBody 565 logger.info("Using pre-read request body, length: ${requestBody?.length()}")
546 try { 566
547 BufferedReader reader = request.getReader() 567 String jsonMethod = request.getMethod()
548 StringBuilder body = new StringBuilder() 568 String jsonAcceptHeader = request.getHeader("Accept")
549 String line 569 String jsonContentType = request.getContentType()
550 while ((line = reader.readLine()) != null) { 570
551 body.append(line) 571 logger.info("Enhanced MCP JSON-RPC Request: ${jsonMethod} ${request.requestURI} - Accept: ${jsonAcceptHeader}, Content-Type: ${jsonContentType}")
552 } 572
553 requestBody = body.toString() 573 // Handle POST requests for JSON-RPC
554 574 if (!"POST".equals(jsonMethod)) {
555 } catch (IOException e) { 575 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
556 logger.error("Failed to read request body: ${e.message}")
557 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
558 response.setContentType("application/json") 576 response.setContentType("application/json")
559 response.writer.write(groovy.json.JsonOutput.toJson([ 577 response.writer.write(groovy.json.JsonOutput.toJson([
560 jsonrpc: "2.0", 578 jsonrpc: "2.0",
561 error: [code: -32700, message: "Failed to read request body: " + e.message], 579 error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."],
562 id: null 580 id: null
563 ])) 581 ]))
564 return 582 return
565 } 583 }
566 584
585 // Use pre-read request body
586 logger.info("Using pre-read request body, length: ${requestBody?.length()}")
587
567 if (!requestBody) { 588 if (!requestBody) {
568 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 589 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
569 response.setContentType("application/json") 590 response.setContentType("application/json")
...@@ -622,6 +643,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -622,6 +643,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
622 643
623 // Get session ID from Mcp-Session-Id header per MCP specification 644 // Get session ID from Mcp-Session-Id header per MCP specification
624 String sessionId = request.getHeader("Mcp-Session-Id") 645 String sessionId = request.getHeader("Mcp-Session-Id")
646 logger.info("Session ID from header: '${sessionId}', method: '${rpcRequest.method}'")
625 647
626 // Validate session ID for non-initialize requests per MCP spec 648 // Validate session ID for non-initialize requests per MCP spec
627 if (!sessionId && rpcRequest.method != "initialize") { 649 if (!sessionId && rpcRequest.method != "initialize") {
...@@ -655,7 +677,23 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -655,7 +677,23 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
655 } 677 }
656 678
657 // Verify user has access to this Visit 679 // Verify user has access to this Visit
658 if (visit.userId && ec.user.userId && visit.userId.toString() != ec.user.userId.toString()) { 680 logger.info("Session access check - visit.userId: ${visit.userId}, ec.user.userId: ${ec.user.userId}")
681
682 // Allow access if:
683 // 1. Visit userId matches current user, OR
684 // 2. Visit was created with ADMIN (for privileged access) but current user is MCP_USER (actual authenticated user)
685 boolean accessAllowed = false
686 if (visit.userId && ec.user.userId) {
687 if (visit.userId.toString() == ec.user.userId.toString()) {
688 accessAllowed = true
689 } else if (visit.userId.toString() == "ADMIN" && ec.user.userId.toString() == "MCP_USER") {
690 // Special case: MCP services run with ADMIN privileges but authenticate as MCP_USER
691 accessAllowed = true
692 logger.info("Allowing MCP privileged access: Visit created with ADMIN, accessed by MCP_USER")
693 }
694 }
695
696 if (!accessAllowed) {
659 response.setStatus(HttpServletResponse.SC_FORBIDDEN) 697 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
660 response.setContentType("application/json") 698 response.setContentType("application/json")
661 response.writer.write(groovy.json.JsonOutput.toJson([ 699 response.writer.write(groovy.json.JsonOutput.toJson([
...@@ -718,6 +756,9 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -718,6 +756,9 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
718 756
719 switch (method) { 757 switch (method) {
720 case "initialize": 758 case "initialize":
759 // Capture actual authenticated user ID before service elevation
760 params.actualUserId = ec.user.userId
761 logger.info("Initialize - actualUserId: ${params.actualUserId}")
721 return callMcpService("mcp#Initialize", params, ec) 762 return callMcpService("mcp#Initialize", params, ec)
722 case "ping": 763 case "ping":
723 // Simple ping for testing - bypass service for now 764 // Simple ping for testing - bypass service for now
......