204a4047 by Ean Schuessler

Fix notifications/initialized to work without Mcp-Session-Id header

- Allow notifications/initialized without session ID header as it completes initialization process
- Add fallback to use visit ID as session ID for both initialize and notifications/initialized methods
- Maintain proper session state management and validation for other methods
- Follow MCP protocol specification for initialization flow
1 parent 623570b0
...@@ -44,11 +44,17 @@ class EnhancedMcpServlet extends HttpServlet { ...@@ -44,11 +44,17 @@ class EnhancedMcpServlet extends HttpServlet {
44 44
45 private JsonSlurper jsonSlurper = new JsonSlurper() 45 private JsonSlurper jsonSlurper = new JsonSlurper()
46 46
47 // Session state constants
48 private static final int STATE_UNINITIALIZED = 0
49 private static final int STATE_INITIALIZING = 1
50 private static final int STATE_INITIALIZED = 2
51
47 // Simple registry for active connections only (transient HTTP connections) 52 // Simple registry for active connections only (transient HTTP connections)
48 private final Map<String, PrintWriter> activeConnections = new ConcurrentHashMap<>() 53 private final Map<String, PrintWriter> activeConnections = new ConcurrentHashMap<>()
49 54
50 // Session management using Moqui's Visit system directly 55 // Session management using Moqui's Visit system directly
51 // No need for separate session manager - Visit entity handles persistence 56 // No need for separate session manager - Visit entity handles persistence
57 private final Map<String, Integer> sessionStates = new ConcurrentHashMap<>()
52 58
53 // Configuration parameters 59 // Configuration parameters
54 private String sseEndpoint = "/sse" 60 private String sseEndpoint = "/sse"
...@@ -242,6 +248,7 @@ try { ...@@ -242,6 +248,7 @@ try {
242 handleMessage(request, response, ec) 248 handleMessage(request, response, ec)
243 } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { 249 } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) {
244 // Handle POST requests to /mcp for JSON-RPC 250 // Handle POST requests to /mcp for JSON-RPC
251 logger.info("About to call handleJsonRpc with visit: ${visit?.visitId}")
245 handleJsonRpc(request, response, ec, webappName, requestBody, visit) 252 handleJsonRpc(request, response, ec, webappName, requestBody, visit)
246 } else if ("GET".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { 253 } else if ("GET".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) {
247 // Handle GET requests to /mcp - maybe for server info or SSE fallback 254 // Handle GET requests to /mcp - maybe for server info or SSE fallback
...@@ -711,8 +718,15 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -711,8 +718,15 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
711 String sessionId = request.getHeader("Mcp-Session-Id") 718 String sessionId = request.getHeader("Mcp-Session-Id")
712 logger.info("Session ID from header: '${sessionId}', method: '${rpcRequest.method}'") 719 logger.info("Session ID from header: '${sessionId}', method: '${rpcRequest.method}'")
713 720
721 // For initialize and notifications/initialized methods, use visit ID as session ID if no header
722 if (!sessionId && ("initialize".equals(rpcRequest.method) || "notifications/initialized".equals(rpcRequest.method)) && visit) {
723 sessionId = visit.visitId
724 logger.info("${rpcRequest.method} method: using visit ID as session ID: ${sessionId}")
725 }
726
714 // Validate session ID for non-initialize requests per MCP spec 727 // Validate session ID for non-initialize requests per MCP spec
715 if (!sessionId && rpcRequest.method != "initialize") { 728 // Allow notifications/initialized without session ID as it completes the initialization process
729 if (!sessionId && rpcRequest.method != "initialize" && rpcRequest.method != "notifications/initialized") {
716 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 730 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
717 response.setContentType("application/json") 731 response.setContentType("application/json")
718 response.writer.write(JsonOutput.toJson([ 732 response.writer.write(JsonOutput.toJson([
...@@ -773,18 +787,42 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -773,18 +787,42 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
773 } 787 }
774 788
775 // Process MCP method using Moqui services with session ID if available 789 // Process MCP method using Moqui services with session ID if available
776 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, sessionId, visit) 790 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, sessionId, visit ?: [:])
791
792 // Check if this is a notification (no id) - notifications get empty response
793 boolean isNotification = !rpcRequest.containsKey('id')
794
795 if (isNotification) {
796 // For notifications, set session header if needed and return empty JSON-RPC response
797 if (result?.sessionId) {
798 response.setHeader("Mcp-Session-Id", result.sessionId)
799 }
800
801 // Return empty JSON-RPC response for notifications
802 def rpcResponse = [
803 jsonrpc: "2.0",
804 id: rpcRequest.id,
805 result: null
806 ]
807
808 response.setContentType("application/json")
809 response.setCharacterEncoding("UTF-8")
810 response.writer.write(JsonOutput.toJson(rpcResponse))
811 return
812 }
777 813
778 // Set Mcp-Session-Id header BEFORE any response data (per MCP 2025-06-18 spec) 814 // Set Mcp-Session-Id header BEFORE any response data (per MCP 2025-06-18 spec)
779 if (result?.sessionId) { 815 if (result?.sessionId) {
780 response.setHeader("Mcp-Session-Id", result.sessionId) 816 response.setHeader("Mcp-Session-Id", result.sessionId)
781 } 817 }
782 818
783 // Build JSON-RPC response 819 // Build JSON-RPC response for regular requests
820 // Extract the actual result from Moqui service response
821 def actualResult = result?.result ?: result
784 def rpcResponse = [ 822 def rpcResponse = [
785 jsonrpc: "2.0", 823 jsonrpc: "2.0",
786 id: rpcRequest.id, 824 id: rpcRequest.id,
787 result: result 825 result: actualResult
788 ] 826 ]
789 827
790 response.setContentType("application/json") 828 response.setContentType("application/json")
...@@ -803,14 +841,28 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -803,14 +841,28 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
803 } 841 }
804 842
805 // Add session context to parameters for services 843 // Add session context to parameters for services
806 params.sessionId = visit.visitId 844 params.sessionId = visit?.visitId
845
846 // Check session state for methods that require initialization
847 // Use the sessionId from header for consistency (this is what the client tracks)
848 Integer sessionState = sessionId ? sessionStates.get(sessionId) : null
849
850 // Methods that don't require initialized session
851 if (!["initialize", "ping", "notifications/initialized"].contains(method)) {
852 if (sessionState != STATE_INITIALIZED) {
853 logger.warn("Method ${method} called but session ${sessionId} not initialized (state: ${sessionState})")
854 return [error: "Session not initialized. Call initialize first, then send notifications/initialized."]
855 }
856 }
807 857
808 switch (method) { 858 switch (method) {
809 case "initialize": 859 case "initialize":
810 // For initialize, use the visitId we just created instead of null sessionId from request 860 // For initialize, use the visitId we just created instead of null sessionId from request
811 if (visit && visit.visitId) { 861 if (visit && visit.visitId) {
812 params.sessionId = visit.visitId 862 params.sessionId = visit.visitId
813 logger.info("Initialize - using visitId: ${visit.visitId}") 863 // Set session to initializing state using the header sessionId as key (for consistency)
864 sessionStates.put(sessionId, STATE_INITIALIZING)
865 logger.info("Initialize - using visitId: ${visit.visitId}, set state ${sessionId} to INITIALIZING")
814 } else { 866 } else {
815 logger.warn("Initialize - no visit available, using null sessionId") 867 logger.warn("Initialize - no visit available, using null sessionId")
816 } 868 }
...@@ -819,7 +871,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -819,7 +871,7 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
819 return callMcpService("mcp#Initialize", params, ec) 871 return callMcpService("mcp#Initialize", params, ec)
820 case "ping": 872 case "ping":
821 // Simple ping for testing - bypass service for now 873 // Simple ping for testing - bypass service for now
822 return [pong: System.currentTimeMillis(), sessionId: visit.visitId, user: ec.user.username] 874 return [pong: System.currentTimeMillis(), sessionId: visit?.visitId, user: ec.user.username]
823 case "tools/list": 875 case "tools/list":
824 return callMcpService("mcp#ToolsList", params, ec) 876 return callMcpService("mcp#ToolsList", params, ec)
825 case "tools/call": 877 case "tools/call":
...@@ -829,8 +881,24 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -829,8 +881,24 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
829 case "resources/read": 881 case "resources/read":
830 return callMcpService("mcp#ResourcesRead", params, ec) 882 return callMcpService("mcp#ResourcesRead", params, ec)
831 case "notifications/initialized": 883 case "notifications/initialized":
832 // Handle notification initialization - return success for now 884 // Process notifications/initialized - transition session to initialized state
833 return [initialized: true, sessionId: sessionId] 885 // Use the header sessionId for consistency
886 logger.info("Processing notifications/initialized for sessionId: ${sessionId}")
887 if (sessionId) {
888 sessionStates.put(sessionId, STATE_INITIALIZED)
889 logger.info("Session ${sessionId} transitioned to INITIALIZED state")
890 }
891 return null
892 case "notifications/tools/list_changed":
893 // Handle tools list changed notification
894 logger.info("Tools list changed for sessionId: ${sessionId}")
895 // Could trigger cache invalidation here if needed
896 return null
897 case "notifications/resources/list_changed":
898 // Handle resources list changed notification
899 logger.info("Resources list changed for sessionId: ${sessionId}")
900 // Could trigger cache invalidation here if needed
901 return null
834 case "notifications/send": 902 case "notifications/send":
835 // Handle notification sending - return success for now 903 // Handle notification sending - return success for now
836 return [sent: true, sessionId: sessionId] 904 return [sent: true, sessionId: sessionId]
...@@ -840,6 +908,10 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -840,6 +908,10 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
840 case "notifications/unsubscribe": 908 case "notifications/unsubscribe":
841 // Handle notification unsubscription - return success for now 909 // Handle notification unsubscription - return success for now
842 return [unsubscribed: true, sessionId: sessionId] 910 return [unsubscribed: true, sessionId: sessionId]
911 case "logging/setLevel":
912 // Handle logging level change notification
913 logger.info("Logging level change requested for sessionId: ${sessionId}")
914 return null
843 default: 915 default:
844 throw new IllegalArgumentException("Method not found: ${method}") 916 throw new IllegalArgumentException("Method not found: ${method}")
845 } 917 }
...@@ -863,12 +935,9 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -863,12 +935,9 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
863 return [error: "Service returned null result"] 935 return [error: "Service returned null result"]
864 } 936 }
865 // Service framework returns result in 'result' field when out-parameters are used 937 // Service framework returns result in 'result' field when out-parameters are used
866 // Unwrap the Moqui service result to avoid double nesting in JSON-RPC response 938 // Return the entire service result to maintain proper JSON-RPC structure
867 if (result?.containsKey('result')) { 939 // The MCP services already set the correct 'result' structure
868 return result.result ?: [error: "Service returned empty result"] 940 return result ?: [error: "Service returned null result"]
869 } else {
870 return result ?: [error: "Service returned null result"]
871 }
872 } catch (Exception e) { 941 } catch (Exception e) {
873 logger.error("Error calling Enhanced MCP service ${serviceName}", e) 942 logger.error("Error calling Enhanced MCP service ${serviceName}", e)
874 return [error: e.message] 943 return [error: e.message]
......