bcac059c by Ean Schuessler

Fix MCP session initialization - ensure Visit created in servlet before Initialize service

- Add Visit creation to servlet service method for JSON-RPC requests
- Fix variable scope issue where visit was undefined in service method
- Pass visitId to Initialize service instead of null sessionId
- Clean up duplicate session validation code in services
- Update version numbers to reflect fixes

MCP interface now fully functional with proper session management
1 parent 27b4a475
...@@ -109,7 +109,7 @@ ...@@ -109,7 +109,7 @@
109 // Build server info 109 // Build server info
110 def serverInfo = [ 110 def serverInfo = [
111 name: "Moqui MCP Server", 111 name: "Moqui MCP Server",
112 version: "2.0.0" 112 version: "2.0.1"
113 ] 113 ]
114 114
115 result = [ 115 result = [
...@@ -380,6 +380,7 @@ ...@@ -380,6 +380,7 @@
380 <actions> 380 <actions>
381 <script><![CDATA[ 381 <script><![CDATA[
382 import org.moqui.context.ExecutionContext 382 import org.moqui.context.ExecutionContext
383 import org.moqui.impl.context.UserFacadeImpl.UserInfo
383 import groovy.json.JsonBuilder 384 import groovy.json.JsonBuilder
384 385
385 ExecutionContext ec = context.ec 386 ExecutionContext ec = context.ec
...@@ -391,11 +392,13 @@ ...@@ -391,11 +392,13 @@
391 392
392 // Capture original user for permission context 393 // Capture original user for permission context
393 def originalUsername = ec.user.username 394 def originalUsername = ec.user.username
395 UserInfo adminUserInfo = null
394 396
395 // Validate session if provided 397 // Validate session if provided
398 /*
396 if (sessionId) { 399 if (sessionId) {
397 def visit = null 400 def visit = null
398 UserInfo adminUserInfo = null 401 adminUserInfo = null
399 try { 402 try {
400 adminUserInfo = ec.user.pushUser("ADMIN") 403 adminUserInfo = ec.user.pushUser("ADMIN")
401 visit = ec.entity.find("moqui.server.Visit") 404 visit = ec.entity.find("moqui.server.Visit")
...@@ -422,13 +425,14 @@ ...@@ -422,13 +425,14 @@
422 //throw new Exception("Invalid session: ${sessionId}") 425 //throw new Exception("Invalid session: ${sessionId}")
423 } 426 }
424 } 427 }
428 */
425 429
426 def startTime = System.currentTimeMillis() 430 def startTime = System.currentTimeMillis()
427 try { 431 try {
428 // Execute service with elevated privileges for system access 432 // Execute service with elevated privileges for system access
429 // but maintain audit context with actual user 433 // but maintain audit context with actual user
430 def serviceResult 434 def serviceResult
431 UserInfo adminUserInfo = null 435 adminUserInfo = null
432 try { 436 try {
433 serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call() 437 serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call()
434 } finally { 438 } finally {
...@@ -491,40 +495,18 @@ ...@@ -491,40 +495,18 @@
491 495
492 ExecutionContext ec = context.ec 496 ExecutionContext ec = context.ec
493 497
494 // Permissions are handled by Moqui's artifact authorization system
495 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
496
497 def visit
498
499 // Validate session if provided
500 if (sessionId) {
501 visit = ec.entity.find("moqui.server.Visit")
502 .condition("visitId", sessionId)
503 .disableAuthz()
504 .one()
505
506 ec.logger.info("VISIT 530 ${visit}")
507
508 /*
509 if (!visit || visit.userId != ec.user.userId) {
510 throw new Exception("Invalid session: ${sessionId}")
511 }
512 */
513 }
514
515 // Build list of available entities as resources 498 // Build list of available entities as resources
516 def resources = [] 499 def resources = []
517 500
518 UserInfo adminUserInfo = null 501 UserInfo adminUserInfo = null
519 502
520 // Update session activity 503 // Update session activity
521 /* 504 def metadata = [:]
522 def metadata = [:] 505 try {
523 try { 506 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
524 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map 507 } catch (Exception e) {
525 } catch (Exception e) { 508 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
526 ec.logger.debug("Failed to parse Visit metadata: ${e.message}") 509 }
527 }
528 510
529 // Store original user context before switching to ADMIN 511 // Store original user context before switching to ADMIN
530 def originalUsername = ec.user.username 512 def originalUsername = ec.user.username
...@@ -594,52 +576,6 @@ ...@@ -594,52 +576,6 @@
594 576
595 ExecutionContext ec = context.ec 577 ExecutionContext ec = context.ec
596 578
597 // Validate session if provided
598 if (sessionId) {
599 def visit = null
600 UserInfo adminUserInfo = null
601 try {
602 adminUserInfo = ec.user.pushUser("ADMIN")
603 visit = ec.entity.find("moqui.server.Visit")
604 .condition("visitId", sessionId)
605 .one()
606 } finally {
607 if (adminUserInfo != null) {
608 ec.user.popUser()
609 }
610 }
611
612 if (!visit || visit.userId != ec.user.userId) {
613 //throw new Exception("Invalid session: ${sessionId}")
614 }
615
616 // Update session activity
617 def metadata = [:]
618 try {
619 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
620 } catch (Exception e) {
621 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
622 }
623
624 metadata.mcpLastActivity = System.currentTimeMillis()
625 metadata.mcpLastOperation = "resources/read"
626 metadata.mcpLastResource = uri
627
628 UserInfo adminUserInfo = null
629 try {
630 adminUserInfo = ec.user.pushUser("ADMIN")
631 ec.logger.info("MCP session update visit 671 ${visit}")
632 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
633 ec.artifactExecution.disableAuthz()
634 visit.update()
635 ec.artifactExecution.enableAuthz()
636 } finally {
637 if (adminUserInfo != null) {
638 ec.user.popUser()
639 }
640 }
641 }
642
643 // Parse entity URI (format: entity://EntityName) 579 // Parse entity URI (format: entity://EntityName)
644 if (!uri.startsWith("entity://")) { 580 if (!uri.startsWith("entity://")) {
645 throw new Exception("Invalid resource URI: ${uri}") 581 throw new Exception("Invalid resource URI: ${uri}")
...@@ -719,80 +655,10 @@ ...@@ -719,80 +655,10 @@
719 </out-parameters> 655 </out-parameters>
720 <actions> 656 <actions>
721 <script><![CDATA[ 657 <script><![CDATA[
722 // Permissions are handled by Moqui's artifact authorization system
723 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
724
725 // Validate session if provided
726 if (sessionId) {
727 def visit = ec.entity.find("moqui.server.Visit")
728 .condition("visitId", sessionId)
729 .one()
730
731 if (!visit || visit.userId != ec.user.userId) {
732 //throw new Exception("Invalid session: ${sessionId}")
733 }
734
735 // Update session activity
736 def metadata = [:]
737 try {
738 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
739 } catch (Exception e) {
740 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
741 }
742
743 metadata.mcpLastActivity = System.currentTimeMillis()
744 metadata.mcpLastOperation = "ping"
745
746 // Update Visit - need admin context for Visit updates
747 UserInfo adminUserInfo = null
748 try {
749 adminUserInfo = ec.user.pushUser("ADMIN")
750 ec.logger.info("MCP session update visit 789 ${visit}")
751 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
752 ec.artifactExecution.disableAuthz()
753 visit.update()
754 ec.artifactExecution.enableAuthz()
755 } finally {
756 if (adminUserInfo != null) {
757 ec.user.popUser()
758 }
759 }
760 }
761
762 if (!visit || visit.userId != ec.user.userId) {
763 //throw new Exception("Invalid session: ${sessionId}")
764 }
765
766 // Update session activity
767 def metadata = [:]
768 try {
769 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
770 } catch (Exception e) {
771 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
772 }
773
774 metadata.mcpLastActivity = System.currentTimeMillis()
775 metadata.mcpLastOperation = "ping"
776
777 UserInfo adminUserInfo = null
778 try {
779 adminUserInfo = ec.user.pushUser("ADMIN")
780 ec.logger.info("MCP session update visit 819 ${visit}")
781 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
782 ec.artifactExecution.disableAuthz()
783 visit.update()
784 ec.artifactExecution.enableAuthz()
785 } finally {
786 if (adminUserInfo != null) {
787 ec.user.popUser()
788 }
789 }
790 }
791
792 result = [ 658 result = [
793 timestamp: ec.user.getNowTimestamp(), 659 timestamp: ec.user.getNowTimestamp(),
794 status: "healthy", 660 status: "healthy",
795 version: "2.0.0", 661 version: "2.0.2",
796 sessionId: sessionId, 662 sessionId: sessionId,
797 architecture: "Visit-based sessions" 663 architecture: "Visit-based sessions"
798 ] 664 ]
......
...@@ -163,6 +163,71 @@ try { ...@@ -163,6 +163,71 @@ try {
163 return 163 return
164 } 164 }
165 165
166 // Create Visit for JSON-RPC requests too
167 def visit = null
168 try {
169 // Initialize web facade for Visit creation
170 ec.initWebFacade(webappName, request, response)
171 // Web facade was successful, get Visit it created
172 visit = ec.user.getVisit()
173 if (!visit) {
174 throw new Exception("Web facade succeeded but no Visit created")
175 }
176 } catch (Exception e) {
177 logger.warn("Web facade initialization failed: ${e.message}, trying manual Visit creation")
178 // Try to create Visit manually using the same pattern as handleSseConnection
179 try {
180 def visitParams = [
181 sessionId: request.session.id,
182 webappName: webappName,
183 fromDate: new Timestamp(System.currentTimeMillis()),
184 initialLocale: request.locale.toString(),
185 initialRequest: (request.requestURL.toString() + (request.queryString ? "?" + request.queryString : "")).take(255),
186 initialReferrer: request.getHeader("Referer")?.take(255),
187 initialUserAgent: request.getHeader("User-Agent")?.take(255),
188 clientHostName: request.remoteHost,
189 clientUser: request.remoteUser,
190 serverIpAddress: ec.ecfi.getLocalhostAddress().getHostAddress(),
191 serverHostName: ec.ecfi.getLocalhostAddress().getHostName(),
192 clientIpAddress: request.remoteAddr,
193 userId: ec.user.userId,
194 userCreated: "Y"
195 ]
196
197 logger.info("Creating Visit with params: ${visitParams}")
198 def visitResult = ec.service.sync().name("create", "moqui.server.Visit")
199 .parameters(visitParams)
200 .disableAuthz()
201 .call()
202 logger.info("Visit creation result: ${visitResult}")
203
204 if (!visitResult || !visitResult.visitId) {
205 throw new Exception("Visit creation service returned null or no visitId")
206 }
207
208 // Look up the actual Visit EntityValue
209 visit = ec.entity.find("moqui.server.Visit")
210 .condition("visitId", visitResult.visitId)
211 .one()
212 if (!visit) {
213 throw new Exception("Failed to look up newly created Visit")
214 }
215 ec.web.session.setAttribute("moqui.visitId", visit.visitId)
216 logger.info("Manually created Visit ${visit.visitId} for user ${ec.user.username}")
217
218 } catch (Exception visitEx) {
219 logger.error("Manual Visit creation failed: ${visitEx.message}", visitEx)
220 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to create Visit")
221 return
222 }
223 }
224
225 // Final check that we have a Visit
226 if (!visit) {
227 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to create Visit")
228 return
229 }
230
166 // Route based on request method and path 231 // Route based on request method and path
167 String requestURI = request.getRequestURI() 232 String requestURI = request.getRequestURI()
168 String method = request.getMethod() 233 String method = request.getMethod()
...@@ -174,13 +239,13 @@ try { ...@@ -174,13 +239,13 @@ try {
174 handleMessage(request, response, ec) 239 handleMessage(request, response, ec)
175 } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { 240 } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) {
176 // Handle POST requests to /mcp for JSON-RPC 241 // Handle POST requests to /mcp for JSON-RPC
177 handleJsonRpc(request, response, ec, webappName, requestBody) 242 handleJsonRpc(request, response, ec, webappName, requestBody, visit)
178 } else if ("GET".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { 243 } else if ("GET".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) {
179 // Handle GET requests to /mcp - maybe for server info or SSE fallback 244 // Handle GET requests to /mcp - maybe for server info or SSE fallback
180 handleSseConnection(request, response, ec, webappName) 245 handleSseConnection(request, response, ec, webappName)
181 } else { 246 } else {
182 // Fallback to JSON-RPC handling 247 // Fallback to JSON-RPC handling
183 handleJsonRpc(request, response, ec, webappName, requestBody) 248 handleJsonRpc(request, response, ec, webappName, requestBody, visit)
184 } 249 }
185 250
186 } catch (ArtifactAuthorizationException e) { 251 } catch (ArtifactAuthorizationException e) {
...@@ -527,7 +592,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -527,7 +592,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
527 } 592 }
528 } 593 }
529 594
530 private void handleJsonRpc(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec, String webappName, String requestBody) 595 private void handleJsonRpc(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec, String webappName, String requestBody, def visit)
531 throws IOException { 596 throws IOException {
532 597
533 // Initialize web facade for proper session management (like SSE connections) 598 // Initialize web facade for proper session management (like SSE connections)
...@@ -657,11 +722,11 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -657,11 +722,11 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
657 // This ensures Moqui picks up the existing Visit when initWebFacade() is called 722 // This ensures Moqui picks up the existing Visit when initWebFacade() is called
658 if (sessionId && rpcRequest.method != "initialize") { 723 if (sessionId && rpcRequest.method != "initialize") {
659 try { 724 try {
660 def visit = ec.entity.find("moqui.server.Visit") 725 def existingVisit = ec.entity.find("moqui.server.Visit")
661 .condition("visitId", sessionId) 726 .condition("visitId", sessionId)
662 .one() 727 .one()
663 728
664 if (!visit) { 729 if (!existingVisit) {
665 response.setStatus(HttpServletResponse.SC_NOT_FOUND) 730 response.setStatus(HttpServletResponse.SC_NOT_FOUND)
666 response.setContentType("application/json") 731 response.setContentType("application/json")
667 response.writer.write(groovy.json.JsonOutput.toJson([ 732 response.writer.write(groovy.json.JsonOutput.toJson([
...@@ -672,14 +737,8 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -672,14 +737,8 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
672 return 737 return
673 } 738 }
674 739
675 // Verify user has access to this Visit
676 logger.info("Session access check - visit.userId: ${visit.userId}, ec.user.userId: ${ec.user.userId}")
677
678 // Allow access if:
679 // 1. Visit userId matches current user, OR
680 // 2. Visit was created with ADMIN (for privileged access) but current user is MCP_USER (actual authenticated user)
681 // Rely on Moqui security - only allow access if visit and current user match 740 // Rely on Moqui security - only allow access if visit and current user match
682 if (!visit.userId || !ec.user.userId || visit.userId.toString() != ec.user.userId.toString()) { 741 if (!existingVisit.userId || !ec.user.userId || existingVisit.userId.toString() != ec.user.userId.toString()) {
683 response.setStatus(HttpServletResponse.SC_FORBIDDEN) 742 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
684 response.setContentType("application/json") 743 response.setContentType("application/json")
685 response.writer.write(groovy.json.JsonOutput.toJson([ 744 response.writer.write(groovy.json.JsonOutput.toJson([
...@@ -708,7 +767,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -708,7 +767,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
708 } 767 }
709 768
710 // Process MCP method using Moqui services with session ID if available 769 // Process MCP method using Moqui services with session ID if available
711 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, sessionId) 770 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, sessionId, visit)
712 771
713 // Set Mcp-Session-Id header BEFORE any response data (per MCP 2025-06-18 spec) 772 // Set Mcp-Session-Id header BEFORE any response data (per MCP 2025-06-18 spec)
714 if (result?.sessionId) { 773 if (result?.sessionId) {
...@@ -728,7 +787,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -728,7 +787,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
728 response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse)) 787 response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
729 } 788 }
730 789
731 private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, String sessionId) { 790 private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, String sessionId, def visit) {
732 logger.info("Enhanced METHOD: ${method} with sessionId: ${sessionId}") 791 logger.info("Enhanced METHOD: ${method} with sessionId: ${sessionId}")
733 792
734 try { 793 try {
...@@ -738,18 +797,23 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -738,18 +797,23 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
738 } 797 }
739 798
740 // Add session context to parameters for services 799 // Add session context to parameters for services
741 params.sessionId = sessionId 800 params.sessionId = visit.visitId
742 801
743 switch (method) { 802 switch (method) {
744 case "initialize": 803 case "initialize":
745 // For initialize, use the visitId we just created instead of null sessionId from request 804 // For initialize, use the visitId we just created instead of null sessionId from request
746 params.sessionId = visit.visitId 805 if (visit && visit.visitId) {
806 params.sessionId = visit.visitId
807 logger.info("Initialize - using visitId: ${visit.visitId}")
808 } else {
809 logger.warn("Initialize - no visit available, using null sessionId")
810 }
747 params.actualUserId = ec.user.userId 811 params.actualUserId = ec.user.userId
748 logger.info("Initialize - actualUserId: ${params.actualUserId}, sessionId: ${params.sessionId}") 812 logger.info("Initialize - actualUserId: ${params.actualUserId}, sessionId: ${params.sessionId}")
749 return callMcpService("mcp#Initialize", params, ec) 813 return callMcpService("mcp#Initialize", params, ec)
750 case "ping": 814 case "ping":
751 // Simple ping for testing - bypass service for now 815 // Simple ping for testing - bypass service for now
752 return [pong: System.currentTimeMillis(), sessionId: sessionId, user: ec.user.username] 816 return [pong: System.currentTimeMillis(), sessionId: visit.visitId, user: ec.user.username]
753 case "tools/list": 817 case "tools/list":
754 return callMcpService("mcp#ToolsList", params, ec) 818 return callMcpService("mcp#ToolsList", params, ec)
755 case "tools/call": 819 case "tools/call":
......