Consolidate MCP implementation with shared JSON-RPC classes
- Extract JsonRpcMessage classes to separate file for better code organization - Remove deprecated McpSessionManager (unused, replaced by Visit-based sessions) - Remove problematic ServiceBasedMcpServlet (async limitations, service invocation bugs) - Enhance EnhancedMcpServlet with configuration parameters and improved monitoring - Add broadcast success/failure counting and helper methods - Fix variable scope issue with requestBody in JSON-RPC handler - Consolidate to single, working MCP servlet implementation Working features: - Authentication with Basic auth - SSE connections with proper session management - JSON-RPC protocol (ping, initialize, tools/list) - Visit-based session persistence - Service delegation to McpServices.xml
Showing
4 changed files
with
116 additions
and
1047 deletions
| ... | @@ -32,46 +32,7 @@ import java.util.concurrent.ConcurrentHashMap | ... | @@ -32,46 +32,7 @@ import java.util.concurrent.ConcurrentHashMap |
| 32 | import java.util.concurrent.atomic.AtomicBoolean | 32 | import java.util.concurrent.atomic.AtomicBoolean |
| 33 | import java.util.UUID | 33 | import java.util.UUID |
| 34 | 34 | ||
| 35 | /** | ||
| 36 | * Simple JSON-RPC Message classes for MCP compatibility | ||
| 37 | */ | ||
| 38 | class JsonRpcMessage { | ||
| 39 | String jsonrpc = "2.0" | ||
| 40 | } | ||
| 41 | |||
| 42 | class JsonRpcResponse extends JsonRpcMessage { | ||
| 43 | Object id | ||
| 44 | Object result | ||
| 45 | Map error | ||
| 46 | |||
| 47 | JsonRpcResponse(Object result, Object id) { | ||
| 48 | this.result = result | ||
| 49 | this.id = id | ||
| 50 | } | ||
| 51 | |||
| 52 | JsonRpcResponse(Map error, Object id) { | ||
| 53 | this.error = error | ||
| 54 | this.id = id | ||
| 55 | } | ||
| 56 | |||
| 57 | String toJson() { | ||
| 58 | return JsonOutput.toJson(this) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | 35 | ||
| 62 | class JsonRpcNotification extends JsonRpcMessage { | ||
| 63 | String method | ||
| 64 | Object params | ||
| 65 | |||
| 66 | JsonRpcNotification(String method, Object params = null) { | ||
| 67 | this.method = method | ||
| 68 | this.params = params | ||
| 69 | } | ||
| 70 | |||
| 71 | String toJson() { | ||
| 72 | return JsonOutput.toJson(this) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | 36 | ||
| 76 | /** | 37 | /** |
| 77 | * Enhanced MCP Servlet with proper SSE handling inspired by HttpServletSseServerTransportProvider | 38 | * Enhanced MCP Servlet with proper SSE handling inspired by HttpServletSseServerTransportProvider |
| ... | @@ -88,12 +49,28 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -88,12 +49,28 @@ class EnhancedMcpServlet extends HttpServlet { |
| 88 | // Session management using Moqui's Visit system directly | 49 | // Session management using Moqui's Visit system directly |
| 89 | // No need for separate session manager - Visit entity handles persistence | 50 | // No need for separate session manager - Visit entity handles persistence |
| 90 | 51 | ||
| 52 | // Configuration parameters | ||
| 53 | private String sseEndpoint = "/sse" | ||
| 54 | private String messageEndpoint = "/message" | ||
| 55 | private int keepAliveIntervalSeconds = 30 | ||
| 56 | private int maxConnections = 100 | ||
| 57 | |||
| 91 | @Override | 58 | @Override |
| 92 | void init(ServletConfig config) throws ServletException { | 59 | void init(ServletConfig config) throws ServletException { |
| 93 | super.init(config) | 60 | super.init(config) |
| 61 | |||
| 62 | // Read configuration from servlet init parameters | ||
| 63 | sseEndpoint = config.getInitParameter("sseEndpoint") ?: sseEndpoint | ||
| 64 | messageEndpoint = config.getInitParameter("messageEndpoint") ?: messageEndpoint | ||
| 65 | keepAliveIntervalSeconds = config.getInitParameter("keepAliveIntervalSeconds")?.toInteger() ?: keepAliveIntervalSeconds | ||
| 66 | maxConnections = config.getInitParameter("maxConnections")?.toInteger() ?: maxConnections | ||
| 67 | |||
| 94 | String webappName = config.getInitParameter("moqui-name") ?: | 68 | String webappName = config.getInitParameter("moqui-name") ?: |
| 95 | config.getServletContext().getInitParameter("moqui-name") | 69 | config.getServletContext().getInitParameter("moqui-name") |
| 70 | |||
| 96 | logger.info("EnhancedMcpServlet initialized for webapp ${webappName}") | 71 | logger.info("EnhancedMcpServlet initialized for webapp ${webappName}") |
| 72 | logger.info("SSE endpoint: ${sseEndpoint}, Message endpoint: ${messageEndpoint}") | ||
| 73 | logger.info("Keep-alive interval: ${keepAliveIntervalSeconds}s, Max connections: ${maxConnections}") | ||
| 97 | } | 74 | } |
| 98 | 75 | ||
| 99 | @Override | 76 | @Override |
| ... | @@ -542,11 +519,6 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") | ... | @@ -542,11 +519,6 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") |
| 542 | 519 | ||
| 543 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}") | 520 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}") |
| 544 | 521 | ||
| 545 | // Log request body for debugging (be careful with this in production) | ||
| 546 | if (requestBody?.length() > 0) { | ||
| 547 | logger.info("MCP JSON-RPC request body: ${requestBody}") | ||
| 548 | } | ||
| 549 | |||
| 550 | // Handle POST requests for JSON-RPC | 522 | // Handle POST requests for JSON-RPC |
| 551 | if (!"POST".equals(method)) { | 523 | if (!"POST".equals(method)) { |
| 552 | response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) | 524 | response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) |
| ... | @@ -593,6 +565,11 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") | ... | @@ -593,6 +565,11 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") |
| 593 | return | 565 | return |
| 594 | } | 566 | } |
| 595 | 567 | ||
| 568 | // Log request body for debugging (be careful with this in production) | ||
| 569 | if (requestBody.length() > 0) { | ||
| 570 | logger.info("MCP JSON-RPC request body: ${requestBody}") | ||
| 571 | } | ||
| 572 | |||
| 596 | def rpcRequest | 573 | def rpcRequest |
| 597 | try { | 574 | try { |
| 598 | rpcRequest = jsonSlurper.parseText(requestBody) | 575 | rpcRequest = jsonSlurper.parseText(requestBody) |
| ... | @@ -777,25 +754,55 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") | ... | @@ -777,25 +754,55 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") |
| 777 | 754 | ||
| 778 | logger.info("Broadcasting to ${mcpVisits.size()} MCP visits, ${activeConnections.size()} active connections") | 755 | logger.info("Broadcasting to ${mcpVisits.size()} MCP visits, ${activeConnections.size()} active connections") |
| 779 | 756 | ||
| 757 | int successCount = 0 | ||
| 758 | int failureCount = 0 | ||
| 759 | |||
| 780 | // Send to active connections (transient) | 760 | // Send to active connections (transient) |
| 781 | mcpVisits.each { visit -> | 761 | mcpVisits.each { visit -> |
| 782 | PrintWriter writer = activeConnections.get(visit.visitId) | 762 | PrintWriter writer = activeConnections.get(visit.visitId) |
| 783 | if (writer && !writer.checkError()) { | 763 | if (writer && !writer.checkError()) { |
| 784 | try { | 764 | try { |
| 785 | sendSseEvent(writer, "broadcast", message.toJson()) | 765 | sendSseEvent(writer, "broadcast", message.toJson()) |
| 766 | successCount++ | ||
| 786 | } catch (Exception e) { | 767 | } catch (Exception e) { |
| 787 | logger.warn("Failed to send broadcast to ${visit.visitId}: ${e.message}") | 768 | logger.warn("Failed to send broadcast to ${visit.visitId}: ${e.message}") |
| 788 | // Remove broken connection | 769 | // Remove broken connection |
| 789 | activeConnections.remove(visit.visitId) | 770 | activeConnections.remove(visit.visitId) |
| 771 | failureCount++ | ||
| 790 | } | 772 | } |
| 773 | } else { | ||
| 774 | // No active connection for this visit | ||
| 775 | failureCount++ | ||
| 791 | } | 776 | } |
| 792 | } | 777 | } |
| 778 | |||
| 779 | logger.info("Broadcast completed: ${successCount} successful, ${failureCount} failed") | ||
| 780 | |||
| 793 | } catch (Exception e) { | 781 | } catch (Exception e) { |
| 794 | logger.error("Error broadcasting to all sessions: ${e.message}", e) | 782 | logger.error("Error broadcasting to all sessions: ${e.message}", e) |
| 795 | } | 783 | } |
| 796 | } | 784 | } |
| 797 | 785 | ||
| 798 | /** | 786 | /** |
| 787 | * Send SSE event to specific session (helper method) | ||
| 788 | */ | ||
| 789 | void sendToSession(String sessionId, JsonRpcMessage message) { | ||
| 790 | try { | ||
| 791 | PrintWriter writer = activeConnections.get(sessionId) | ||
| 792 | if (writer && !writer.checkError()) { | ||
| 793 | sendSseEvent(writer, "message", message.toJson()) | ||
| 794 | logger.debug("Sent message to session ${sessionId}") | ||
| 795 | } else { | ||
| 796 | logger.warn("No active connection for session ${sessionId}") | ||
| 797 | } | ||
| 798 | } catch (Exception e) { | ||
| 799 | logger.error("Error sending message to session ${sessionId}: ${e.message}", e) | ||
| 800 | // Remove broken connection | ||
| 801 | activeConnections.remove(sessionId) | ||
| 802 | } | ||
| 803 | } | ||
| 804 | |||
| 805 | /** | ||
| 799 | * Get session statistics for monitoring | 806 | * Get session statistics for monitoring |
| 800 | */ | 807 | */ |
| 801 | Map getSessionStatistics() { | 808 | Map getSessionStatistics() { |
| ... | @@ -808,12 +815,22 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") | ... | @@ -808,12 +815,22 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") |
| 808 | return [ | 815 | return [ |
| 809 | totalMcpVisits: mcpVisits.size(), | 816 | totalMcpVisits: mcpVisits.size(), |
| 810 | activeConnections: activeConnections.size(), | 817 | activeConnections: activeConnections.size(), |
| 818 | maxConnections: maxConnections, | ||
| 811 | architecture: "Visit-based sessions with connection registry", | 819 | architecture: "Visit-based sessions with connection registry", |
| 812 | message: "Enhanced MCP with session tracking" | 820 | message: "Enhanced MCP with session tracking", |
| 821 | endpoints: [ | ||
| 822 | sse: sseEndpoint, | ||
| 823 | message: messageEndpoint | ||
| 824 | ], | ||
| 825 | keepAliveInterval: keepAliveIntervalSeconds | ||
| 813 | ] | 826 | ] |
| 814 | } catch (Exception e) { | 827 | } catch (Exception e) { |
| 815 | logger.error("Error getting session statistics: ${e.message}", e) | 828 | logger.error("Error getting session statistics: ${e.message}", e) |
| 816 | return [activeSessions: activeConnections.size(), error: e.message] | 829 | return [ |
| 830 | activeConnections: activeConnections.size(), | ||
| 831 | maxConnections: maxConnections, | ||
| 832 | error: e.message | ||
| 833 | ] | ||
| 817 | } | 834 | } |
| 818 | } | 835 | } |
| 819 | } | 836 | } |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| 1 | /* | ||
| 2 | * This software is in the public domain under CC0 1.0 Universal plus a | ||
| 3 | * Grant of Patent License. | ||
| 4 | * | ||
| 5 | * To the extent possible under law, author(s) have dedicated all | ||
| 6 | * copyright and related and neighboring rights to this software to the | ||
| 7 | * public domain worldwide. This software is distributed without any | ||
| 8 | * warranty. | ||
| 9 | * | ||
| 10 | * You should have received a copy of the CC0 Public Domain Dedication | ||
| 11 | * along with this software (see the LICENSE.md file). If not, see | ||
| 12 | * <http://creativecommons.org/publicdomain/zero/1.0/>. | ||
| 13 | */ | ||
| 14 | package org.moqui.mcp | ||
| 15 | |||
| 16 | import groovy.json.JsonOutput | ||
| 17 | |||
| 18 | /** | ||
| 19 | * Simple JSON-RPC Message classes for MCP compatibility | ||
| 20 | */ | ||
| 21 | class JsonRpcMessage { | ||
| 22 | String jsonrpc = "2.0" | ||
| 23 | |||
| 24 | String toJson() { | ||
| 25 | return JsonOutput.toJson(this) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | class JsonRpcResponse extends JsonRpcMessage { | ||
| 30 | Object id | ||
| 31 | Object result | ||
| 32 | Map error | ||
| 33 | |||
| 34 | JsonRpcResponse(Object result, Object id) { | ||
| 35 | this.result = result | ||
| 36 | this.id = id | ||
| 37 | } | ||
| 38 | |||
| 39 | JsonRpcResponse(Map error, Object id) { | ||
| 40 | this.error = error | ||
| 41 | this.id = id | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | class JsonRpcNotification extends JsonRpcMessage { | ||
| 46 | String method | ||
| 47 | Object params | ||
| 48 | |||
| 49 | JsonRpcNotification(String method, Object params = null) { | ||
| 50 | this.method = method | ||
| 51 | this.params = params | ||
| 52 | } | ||
| 53 | } | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 1 | /* | ||
| 2 | * This software is in the public domain under CC0 1.0 Universal plus a | ||
| 3 | * Grant of Patent License. | ||
| 4 | * | ||
| 5 | * To the extent possible under law, author(s) have dedicated all | ||
| 6 | * copyright and related and neighboring rights to this software to the | ||
| 7 | * public domain worldwide. This software is distributed without any | ||
| 8 | * warranty. | ||
| 9 | * | ||
| 10 | * You should have received a copy of the CC0 Public Domain Dedication | ||
| 11 | * along with this software (see the LICENSE.md file). If not, see | ||
| 12 | * <http://creativecommons.org/publicdomain/zero/1.0/>. | ||
| 13 | */ | ||
| 14 | package org.moqui.mcp | ||
| 15 | |||
| 16 | import org.slf4j.Logger | ||
| 17 | import org.slf4j.LoggerFactory | ||
| 18 | |||
| 19 | import java.util.concurrent.ConcurrentHashMap | ||
| 20 | import java.util.concurrent.Executors | ||
| 21 | import java.util.concurrent.ScheduledExecutorService | ||
| 22 | import java.util.concurrent.TimeUnit | ||
| 23 | import java.util.concurrent.atomic.AtomicBoolean | ||
| 24 | |||
| 25 | /** | ||
| 26 | * MCP Session Manager with SDK-style capabilities | ||
| 27 | * | ||
| 28 | * @deprecated This class is deprecated. Use Moqui's Visit entity directly for session management. | ||
| 29 | * See VisitBasedMcpSession for the new Visit-based approach. | ||
| 30 | * | ||
| 31 | * Provides centralized session management, broadcasting, and graceful shutdown | ||
| 32 | */ | ||
| 33 | @Deprecated | ||
| 34 | class McpSessionManager { | ||
| 35 | protected final static Logger logger = LoggerFactory.getLogger(McpSessionManager.class) | ||
| 36 | |||
| 37 | private final Map<String, VisitBasedMcpSession> sessions = new ConcurrentHashMap<>() | ||
| 38 | private final AtomicBoolean isShuttingDown = new AtomicBoolean(false) | ||
| 39 | private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2) | ||
| 40 | |||
| 41 | // Session cleanup and monitoring | ||
| 42 | private final long sessionTimeoutMs = 30 * 60 * 1000 // 30 minutes | ||
| 43 | private final long cleanupIntervalMs = 5 * 60 * 1000 // 5 minutes | ||
| 44 | |||
| 45 | McpSessionManager() { | ||
| 46 | // Start periodic cleanup task | ||
| 47 | scheduler.scheduleAtFixedRate(this::cleanupInactiveSessions, | ||
| 48 | cleanupIntervalMs, cleanupIntervalMs, TimeUnit.MILLISECONDS) | ||
| 49 | |||
| 50 | logger.info("MCP Session Manager initialized") | ||
| 51 | } | ||
| 52 | |||
| 53 | /** | ||
| 54 | * Register a new session | ||
| 55 | */ | ||
| 56 | void registerSession(VisitBasedMcpSession session) { | ||
| 57 | if (isShuttingDown.get()) { | ||
| 58 | logger.warn("Rejecting session registration during shutdown: ${session.sessionId}") | ||
| 59 | return | ||
| 60 | } | ||
| 61 | |||
| 62 | sessions.put(session.sessionId, session) | ||
| 63 | logger.info("Registered MCP session ${session.sessionId} (total: ${sessions.size()})") | ||
| 64 | |||
| 65 | // Send welcome message to new session | ||
| 66 | def welcomeMessage = new JsonRpcNotification("welcome", [ | ||
| 67 | sessionId: session.sessionId, | ||
| 68 | totalSessions: sessions.size(), | ||
| 69 | timestamp: System.currentTimeMillis() | ||
| 70 | ]) | ||
| 71 | session.sendMessage(welcomeMessage) | ||
| 72 | } | ||
| 73 | |||
| 74 | /** | ||
| 75 | * Unregister a session | ||
| 76 | */ | ||
| 77 | void unregisterSession(String sessionId) { | ||
| 78 | def session = sessions.remove(sessionId) | ||
| 79 | if (session) { | ||
| 80 | logger.info("Unregistered MCP session ${sessionId} (remaining: ${sessions.size()})") | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | /** | ||
| 85 | * Get session by ID | ||
| 86 | */ | ||
| 87 | VisitBasedMcpSession getSession(String sessionId) { | ||
| 88 | return sessions.get(sessionId) | ||
| 89 | } | ||
| 90 | |||
| 91 | /** | ||
| 92 | * Broadcast message to all active sessions | ||
| 93 | */ | ||
| 94 | void broadcast(JsonRpcMessage message) { | ||
| 95 | if (isShuttingDown.get()) { | ||
| 96 | logger.warn("Rejecting broadcast during shutdown") | ||
| 97 | return | ||
| 98 | } | ||
| 99 | |||
| 100 | def inactiveSessions = [] | ||
| 101 | def activeCount = 0 | ||
| 102 | |||
| 103 | sessions.values().each { session -> | ||
| 104 | try { | ||
| 105 | if (session.isActive()) { | ||
| 106 | session.sendMessage(message) | ||
| 107 | activeCount++ | ||
| 108 | } else { | ||
| 109 | inactiveSessions << session.sessionId | ||
| 110 | } | ||
| 111 | } catch (Exception e) { | ||
| 112 | logger.warn("Error broadcasting to session ${session.sessionId}: ${e.message}") | ||
| 113 | inactiveSessions << session.sessionId | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | // Clean up inactive sessions | ||
| 118 | inactiveSessions.each { sessionId -> | ||
| 119 | unregisterSession(sessionId) | ||
| 120 | } | ||
| 121 | |||
| 122 | logger.info("Broadcast message to ${activeCount} active sessions (removed ${inactiveSessions.size()} inactive)") | ||
| 123 | } | ||
| 124 | |||
| 125 | /** | ||
| 126 | * Send message to specific session | ||
| 127 | */ | ||
| 128 | boolean sendToSession(String sessionId, JsonRpcMessage message) { | ||
| 129 | def session = sessions.get(sessionId) | ||
| 130 | if (!session) { | ||
| 131 | return false | ||
| 132 | } | ||
| 133 | |||
| 134 | try { | ||
| 135 | if (session.isActive()) { | ||
| 136 | session.sendMessage(message) | ||
| 137 | return true | ||
| 138 | } else { | ||
| 139 | unregisterSession(sessionId) | ||
| 140 | return false | ||
| 141 | } | ||
| 142 | } catch (Exception e) { | ||
| 143 | logger.warn("Error sending to session ${sessionId}: ${e.message}") | ||
| 144 | unregisterSession(sessionId) | ||
| 145 | return false | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 149 | /** | ||
| 150 | * Get session statistics | ||
| 151 | */ | ||
| 152 | Map getSessionStatistics() { | ||
| 153 | def stats = [ | ||
| 154 | totalSessions: sessions.size(), | ||
| 155 | activeSessions: 0, | ||
| 156 | closingSessions: 0, | ||
| 157 | isShuttingDown: isShuttingDown.get(), | ||
| 158 | uptime: System.currentTimeMillis() - (this.@startTime ?: System.currentTimeMillis()), | ||
| 159 | sessions: [] | ||
| 160 | ] | ||
| 161 | |||
| 162 | sessions.values().each { session -> | ||
| 163 | def sessionStats = session.getSessionStats() | ||
| 164 | stats.sessions << sessionStats | ||
| 165 | |||
| 166 | if (sessionStats.active) { | ||
| 167 | stats.activeSessions++ | ||
| 168 | } | ||
| 169 | if (sessionStats.closing) { | ||
| 170 | stats.closingSessions++ | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | return stats | ||
| 175 | } | ||
| 176 | |||
| 177 | /** | ||
| 178 | * Initiate graceful shutdown | ||
| 179 | */ | ||
| 180 | void shutdownGracefully() { | ||
| 181 | if (!isShuttingDown.compareAndSet(false, true)) { | ||
| 182 | return // Already shutting down | ||
| 183 | } | ||
| 184 | |||
| 185 | logger.info("Initiating graceful MCP session manager shutdown") | ||
| 186 | |||
| 187 | // Send shutdown notification to all sessions | ||
| 188 | def shutdownMessage = new JsonRpcNotification("server_shutdown", [ | ||
| 189 | message: "Server is shutting down gracefully", | ||
| 190 | timestamp: System.currentTimeMillis() | ||
| 191 | ]) | ||
| 192 | broadcast(shutdownMessage) | ||
| 193 | |||
| 194 | // Give sessions time to receive shutdown message | ||
| 195 | scheduler.schedule({ | ||
| 196 | forceShutdown() | ||
| 197 | }, 5, TimeUnit.SECONDS) | ||
| 198 | } | ||
| 199 | |||
| 200 | /** | ||
| 201 | * Force immediate shutdown | ||
| 202 | */ | ||
| 203 | void forceShutdown() { | ||
| 204 | logger.info("Force shutting down MCP session manager") | ||
| 205 | |||
| 206 | // Close all sessions | ||
| 207 | sessions.values().each { session -> | ||
| 208 | try { | ||
| 209 | session.close() | ||
| 210 | } catch (Exception e) { | ||
| 211 | logger.warn("Error closing session ${session.sessionId}: ${e.message}") | ||
| 212 | } | ||
| 213 | } | ||
| 214 | sessions.clear() | ||
| 215 | |||
| 216 | // Shutdown scheduler | ||
| 217 | scheduler.shutdown() | ||
| 218 | try { | ||
| 219 | if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) { | ||
| 220 | scheduler.shutdownNow() | ||
| 221 | } | ||
| 222 | } catch (InterruptedException e) { | ||
| 223 | scheduler.shutdownNow() | ||
| 224 | Thread.currentThread().interrupt() | ||
| 225 | } | ||
| 226 | |||
| 227 | logger.info("MCP session manager shutdown complete") | ||
| 228 | } | ||
| 229 | |||
| 230 | /** | ||
| 231 | * Clean up inactive sessions | ||
| 232 | */ | ||
| 233 | private void cleanupInactiveSessions() { | ||
| 234 | if (isShuttingDown.get()) { | ||
| 235 | return | ||
| 236 | } | ||
| 237 | |||
| 238 | def now = System.currentTimeMillis() | ||
| 239 | def inactiveSessions = [] | ||
| 240 | |||
| 241 | sessions.values().each { session -> | ||
| 242 | def sessionStats = session.getSessionStats() | ||
| 243 | def inactiveTime = now - (sessionStats.lastActivity ?: sessionStats.createdAt.time) | ||
| 244 | |||
| 245 | if (!session.isActive() || inactiveTime > sessionTimeoutMs) { | ||
| 246 | inactiveSessions << session.sessionId | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | inactiveSessions.each { sessionId -> | ||
| 251 | def session = sessions.get(sessionId) | ||
| 252 | if (session) { | ||
| 253 | try { | ||
| 254 | session.closeGracefully() | ||
| 255 | } catch (Exception e) { | ||
| 256 | logger.warn("Error during cleanup of session ${sessionId}: ${e.message}") | ||
| 257 | } | ||
| 258 | unregisterSession(sessionId) | ||
| 259 | } | ||
| 260 | } | ||
| 261 | |||
| 262 | if (inactiveSessions.size() > 0) { | ||
| 263 | logger.info("Cleaned up ${inactiveSessions.size()} inactive MCP sessions") | ||
| 264 | } | ||
| 265 | } | ||
| 266 | |||
| 267 | /** | ||
| 268 | * Get active session count | ||
| 269 | */ | ||
| 270 | int getActiveSessionCount() { | ||
| 271 | return (int) sessions.values().count { it.isActive() } | ||
| 272 | } | ||
| 273 | |||
| 274 | /** | ||
| 275 | * Check if manager is shutting down | ||
| 276 | */ | ||
| 277 | boolean isShuttingDown() { | ||
| 278 | return isShuttingDown.get() | ||
| 279 | } | ||
| 280 | } | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 1 | /* | ||
| 2 | * This software is in the public domain under CC0 1.0 Universal plus a | ||
| 3 | * Grant of Patent License. | ||
| 4 | * | ||
| 5 | * To the extent possible under law, author(s) have dedicated all | ||
| 6 | * copyright and related and neighboring rights to this software to the | ||
| 7 | * public domain worldwide. This software is distributed without any | ||
| 8 | * warranty. | ||
| 9 | * | ||
| 10 | * You should have received a copy of the CC0 Public Domain Dedication | ||
| 11 | * along with this software (see the LICENSE.md file). If not, see | ||
| 12 | * <http://creativecommons.org/publicdomain/zero/1.0/>. | ||
| 13 | */ | ||
| 14 | package org.moqui.mcp | ||
| 15 | |||
| 16 | import groovy.json.JsonSlurper | ||
| 17 | import groovy.json.JsonOutput | ||
| 18 | import org.moqui.impl.context.ExecutionContextFactoryImpl | ||
| 19 | import org.moqui.context.ArtifactAuthorizationException | ||
| 20 | import org.moqui.context.ArtifactTarpitException | ||
| 21 | import org.moqui.impl.context.ExecutionContextImpl | ||
| 22 | import org.moqui.entity.EntityValue | ||
| 23 | import org.slf4j.Logger | ||
| 24 | import org.slf4j.LoggerFactory | ||
| 25 | |||
| 26 | import javax.servlet.AsyncContext | ||
| 27 | import javax.servlet.AsyncListener | ||
| 28 | import javax.servlet.AsyncEvent | ||
| 29 | import javax.servlet.ServletConfig | ||
| 30 | import javax.servlet.ServletException | ||
| 31 | import javax.servlet.http.HttpServlet | ||
| 32 | import javax.servlet.http.HttpServletRequest | ||
| 33 | import javax.servlet.http.HttpServletResponse | ||
| 34 | import java.util.concurrent.ConcurrentHashMap | ||
| 35 | import java.util.concurrent.Executors | ||
| 36 | import java.util.concurrent.ScheduledExecutorService | ||
| 37 | import java.util.concurrent.TimeUnit | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Service-Based MCP Servlet that delegates all business logic to McpServices.xml. | ||
| 41 | * | ||
| 42 | * This servlet improves upon the original MoquiMcpServlet by: | ||
| 43 | * - Properly delegating to existing McpServices.xml instead of reimplementing logic | ||
| 44 | * - Adding SSE support for real-time bidirectional communication | ||
| 45 | * - Providing better session management and error handling | ||
| 46 | * - Supporting async operations for scalability | ||
| 47 | * - Using Visit-based persistence for session management | ||
| 48 | */ | ||
| 49 | class ServiceBasedMcpServlet extends HttpServlet { | ||
| 50 | protected final static Logger logger = LoggerFactory.getLogger(ServiceBasedMcpServlet.class) | ||
| 51 | |||
| 52 | private JsonSlurper jsonSlurper = new JsonSlurper() | ||
| 53 | |||
| 54 | // Session management using Visit-based persistence | ||
| 55 | private final Map<String, VisitBasedMcpSession> activeSessions = new ConcurrentHashMap<>() | ||
| 56 | |||
| 57 | // Executor for async operations and keep-alive pings | ||
| 58 | private ScheduledExecutorService executorService | ||
| 59 | |||
| 60 | // Configuration | ||
| 61 | private String sseEndpoint = "/sse" | ||
| 62 | private String messageEndpoint = "/message" | ||
| 63 | private int keepAliveIntervalSeconds = 30 | ||
| 64 | private int maxConnections = 100 | ||
| 65 | |||
| 66 | @Override | ||
| 67 | void init(ServletConfig config) throws ServletException { | ||
| 68 | super.init(config) | ||
| 69 | |||
| 70 | // Read configuration from servlet init parameters | ||
| 71 | sseEndpoint = config.getInitParameter("sseEndpoint") ?: sseEndpoint | ||
| 72 | messageEndpoint = config.getInitParameter("messageEndpoint") ?: messageEndpoint | ||
| 73 | keepAliveIntervalSeconds = config.getInitParameter("keepAliveIntervalSeconds")?.toInteger() ?: keepAliveIntervalSeconds | ||
| 74 | maxConnections = config.getInitParameter("maxConnections")?.toInteger() ?: maxConnections | ||
| 75 | |||
| 76 | // Initialize executor service | ||
| 77 | executorService = Executors.newScheduledThreadPool(4) | ||
| 78 | |||
| 79 | // Start keep-alive task | ||
| 80 | startKeepAliveTask() | ||
| 81 | |||
| 82 | String webappName = config.getInitParameter("moqui-name") ?: | ||
| 83 | config.getServletContext().getInitParameter("moqui-name") | ||
| 84 | |||
| 85 | logger.info("ServiceBasedMcpServlet initialized for webapp ${webappName}") | ||
| 86 | logger.info("SSE endpoint: ${sseEndpoint}, Message endpoint: ${messageEndpoint}") | ||
| 87 | logger.info("Keep-alive interval: ${keepAliveIntervalSeconds}s, Max connections: ${maxConnections}") | ||
| 88 | logger.info("All business logic delegated to McpServices.xml") | ||
| 89 | } | ||
| 90 | |||
| 91 | @Override | ||
| 92 | void destroy() { | ||
| 93 | super.destroy() | ||
| 94 | |||
| 95 | // Shutdown executor service | ||
| 96 | if (executorService) { | ||
| 97 | executorService.shutdown() | ||
| 98 | try { | ||
| 99 | if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { | ||
| 100 | executorService.shutdownNow() | ||
| 101 | } | ||
| 102 | } catch (InterruptedException e) { | ||
| 103 | executorService.shutdownNow() | ||
| 104 | Thread.currentThread().interrupt() | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | // Close all active sessions | ||
| 109 | activeSessions.values().each { session -> | ||
| 110 | try { | ||
| 111 | session.closeGracefully() | ||
| 112 | } catch (Exception e) { | ||
| 113 | logger.warn("Error closing MCP session: ${e.message}") | ||
| 114 | } | ||
| 115 | } | ||
| 116 | activeSessions.clear() | ||
| 117 | |||
| 118 | logger.info("ServiceBasedMcpServlet destroyed") | ||
| 119 | } | ||
| 120 | |||
| 121 | @Override | ||
| 122 | void service(HttpServletRequest request, HttpServletResponse response) | ||
| 123 | throws ServletException, IOException { | ||
| 124 | |||
| 125 | ExecutionContextFactoryImpl ecfi = | ||
| 126 | (ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory") | ||
| 127 | String webappName = getInitParameter("moqui-name") ?: | ||
| 128 | getServletContext().getInitParameter("moqui-name") | ||
| 129 | |||
| 130 | if (ecfi == null || webappName == null) { | ||
| 131 | response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, | ||
| 132 | "System is initializing, try again soon.") | ||
| 133 | return | ||
| 134 | } | ||
| 135 | |||
| 136 | // Handle CORS | ||
| 137 | if (handleCors(request, response, webappName, ecfi)) return | ||
| 138 | |||
| 139 | String requestURI = request.getRequestURI() | ||
| 140 | String method = request.getMethod() | ||
| 141 | |||
| 142 | logger.info("ServiceBasedMcpServlet routing: method=${method}, requestURI=${requestURI}, sseEndpoint=${sseEndpoint}, messageEndpoint=${messageEndpoint}") | ||
| 143 | |||
| 144 | // Route based on HTTP method and URI pattern (like EnhancedMcpServlet) | ||
| 145 | if ("GET".equals(method) && requestURI.endsWith("/sse")) { | ||
| 146 | handleSseConnection(request, response, ecfi, webappName) | ||
| 147 | } else if ("POST".equals(method) && requestURI.endsWith("/message")) { | ||
| 148 | handleMessage(request, response, ecfi, webappName) | ||
| 149 | } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { | ||
| 150 | // Handle POST requests to /mcp for JSON-RPC | ||
| 151 | handleLegacyRpc(request, response, ecfi, webappName) | ||
| 152 | } else if ("GET".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { | ||
| 153 | // Handle GET requests to /mcp - SSE fallback for server info | ||
| 154 | handleSseConnection(request, response, ecfi, webappName) | ||
| 155 | } else { | ||
| 156 | // Legacy support for /rpc endpoint | ||
| 157 | if (requestURI.startsWith("/rpc")) { | ||
| 158 | handleLegacyRpc(request, response, ecfi, webappName) | ||
| 159 | } else { | ||
| 160 | response.sendError(HttpServletResponse.SC_NOT_FOUND, "MCP endpoint not found") | ||
| 161 | } | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | private void handleSseConnection(HttpServletRequest request, HttpServletResponse response, | ||
| 166 | ExecutionContextFactoryImpl ecfi, String webappName) | ||
| 167 | throws IOException { | ||
| 168 | |||
| 169 | logger.info("New SSE connection request from ${request.remoteAddr}") | ||
| 170 | |||
| 171 | // Check connection limit | ||
| 172 | if (activeSessions.size() >= maxConnections) { | ||
| 173 | response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, | ||
| 174 | "Too many SSE connections") | ||
| 175 | return | ||
| 176 | } | ||
| 177 | |||
| 178 | // Get ExecutionContext for this request | ||
| 179 | ExecutionContextImpl ec = ecfi.getEci() | ||
| 180 | |||
| 181 | // Initialize web facade to create Visit | ||
| 182 | ec.initWebFacade(webappName, request, response) | ||
| 183 | |||
| 184 | // Set SSE headers (matching EnhancedMcpServlet) | ||
| 185 | response.setContentType("text/event-stream") | ||
| 186 | response.setCharacterEncoding("UTF-8") | ||
| 187 | response.setHeader("Cache-Control", "no-cache") | ||
| 188 | response.setHeader("Connection", "keep-alive") | ||
| 189 | response.setHeader("Access-Control-Allow-Origin", "*") | ||
| 190 | response.setHeader("X-Accel-Buffering", "no") // Disable nginx buffering | ||
| 191 | |||
| 192 | // Get or create Visit (Moqui automatically creates Visit) | ||
| 193 | def visit = ec.user.getVisit() | ||
| 194 | if (!visit) { | ||
| 195 | response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to create Visit") | ||
| 196 | return | ||
| 197 | } | ||
| 198 | |||
| 199 | // Create Visit-based session transport | ||
| 200 | VisitBasedMcpSession session = new VisitBasedMcpSession(visit, response.writer, ec) | ||
| 201 | activeSessions.put(visit.visitId, session) | ||
| 202 | |||
| 203 | // Enable async support | ||
| 204 | AsyncContext asyncContext = null | ||
| 205 | if (request.isAsyncSupported()) { | ||
| 206 | asyncContext = request.startAsync(request, response) | ||
| 207 | asyncContext.setTimeout(0) // No timeout | ||
| 208 | logger.info("Service-Based SSE async context created for session ${visit.visitId}") | ||
| 209 | } else { | ||
| 210 | logger.warn("Service-Based SSE async not supported, falling back to blocking mode for session ${visit.visitId}") | ||
| 211 | } | ||
| 212 | |||
| 213 | logger.info("Service-Based SSE connection established: ${visit.visitId} from ${request.remoteAddr}") | ||
| 214 | |||
| 215 | // Send initial connection event (matching EnhancedMcpServlet format) | ||
| 216 | def connectData = [ | ||
| 217 | type: "connected", | ||
| 218 | sessionId: visit.visitId, | ||
| 219 | timestamp: System.currentTimeMillis(), | ||
| 220 | serverInfo: [ | ||
| 221 | name: "Moqui Service-Based MCP Server", | ||
| 222 | version: "2.1.0", | ||
| 223 | protocolVersion: "2025-06-18", | ||
| 224 | architecture: "Service-based with Visit persistence" | ||
| 225 | ] | ||
| 226 | ] | ||
| 227 | sendSseEvent(response.writer, "connect", groovy.json.JsonOutput.toJson(connectData), 0) | ||
| 228 | |||
| 229 | // Send endpoint info for message posting | ||
| 230 | sendSseEvent(response.writer, "endpoint", "/mcp/message?sessionId=" + visit.visitId, 1) | ||
| 231 | |||
| 232 | // Set up connection close handling | ||
| 233 | asyncContext.addListener(new AsyncListener() { | ||
| 234 | @Override | ||
| 235 | void onComplete(AsyncEvent event) throws IOException { | ||
| 236 | activeSessions.remove(visit.visitId) | ||
| 237 | session.close() | ||
| 238 | logger.info("Service-Based SSE connection completed: ${visit.visitId}") | ||
| 239 | } | ||
| 240 | |||
| 241 | @Override | ||
| 242 | void onTimeout(AsyncEvent event) throws IOException { | ||
| 243 | activeSessions.remove(visit.visitId) | ||
| 244 | session.close() | ||
| 245 | logger.info("Service-Based SSE connection timeout: ${visit.visitId}") | ||
| 246 | } | ||
| 247 | |||
| 248 | @Override | ||
| 249 | void onError(AsyncEvent event) throws IOException { | ||
| 250 | activeSessions.remove(visit.visitId) | ||
| 251 | session.close() | ||
| 252 | logger.warn("Service-Based SSE connection error: ${visit.visitId} - ${event.throwable?.message}") | ||
| 253 | } | ||
| 254 | |||
| 255 | @Override | ||
| 256 | void onStartAsync(AsyncEvent event) throws IOException { | ||
| 257 | // No action needed | ||
| 258 | } | ||
| 259 | }) | ||
| 260 | } | ||
| 261 | |||
| 262 | private void handleMessage(HttpServletRequest request, HttpServletResponse response, | ||
| 263 | ExecutionContextFactoryImpl ecfi, String webappName) | ||
| 264 | throws IOException { | ||
| 265 | |||
| 266 | long startTime = System.currentTimeMillis() | ||
| 267 | |||
| 268 | if (logger.traceEnabled) { | ||
| 269 | logger.trace("Start MCP message request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]") | ||
| 270 | } | ||
| 271 | |||
| 272 | ExecutionContextImpl activeEc = ecfi.activeContext.get() | ||
| 273 | if (activeEc != null) { | ||
| 274 | logger.warn("In ServiceBasedMcpServlet.handleMessage there is already an ExecutionContext for user ${activeEc.user.username}") | ||
| 275 | activeEc.destroy() | ||
| 276 | } | ||
| 277 | |||
| 278 | ExecutionContextImpl ec = ecfi.getEci() | ||
| 279 | |||
| 280 | try { | ||
| 281 | // Initialize web facade for authentication | ||
| 282 | ec.initWebFacade(webappName, request, response) | ||
| 283 | |||
| 284 | logger.info("Service-Based MCP Message authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}") | ||
| 285 | |||
| 286 | // Require authentication - do not fallback to admin | ||
| 287 | if (!ec.user?.userId) { | ||
| 288 | logger.warn("Service-Based MCP Request denied - no authenticated user") | ||
| 289 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 290 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) | ||
| 291 | response.setContentType("application/json") | ||
| 292 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 293 | jsonrpc: "2.0", | ||
| 294 | error: [code: -32000, message: "Authentication required. Please provide valid credentials."], | ||
| 295 | id: null | ||
| 296 | ])) | ||
| 297 | return | ||
| 298 | } | ||
| 299 | |||
| 300 | // Handle different HTTP methods | ||
| 301 | String method = request.getMethod() | ||
| 302 | |||
| 303 | if ("GET".equals(method)) { | ||
| 304 | // Handle SSE subscription or status check | ||
| 305 | handleGetMessage(request, response, ec) | ||
| 306 | } else if ("POST".equals(method)) { | ||
| 307 | // Handle JSON-RPC message | ||
| 308 | handlePostMessage(request, response, ec) | ||
| 309 | } else { | ||
| 310 | response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, | ||
| 311 | "Method not allowed. Use GET for SSE subscription or POST for JSON-RPC messages.") | ||
| 312 | } | ||
| 313 | |||
| 314 | } catch (ArtifactAuthorizationException e) { | ||
| 315 | logger.warn("Service-Based MCP Access Forbidden (no authz): " + e.message) | ||
| 316 | sendJsonRpcError(response, -32001, "Access Forbidden: " + e.message, null) | ||
| 317 | } catch (ArtifactTarpitException e) { | ||
| 318 | logger.warn("Service-Based MCP Too Many Requests (tarpit): " + e.message) | ||
| 319 | response.setStatus(429) | ||
| 320 | if (e.getRetryAfterSeconds()) { | ||
| 321 | response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) | ||
| 322 | } | ||
| 323 | sendJsonRpcError(response, -32002, "Too Many Requests: " + e.message, null) | ||
| 324 | } catch (Throwable t) { | ||
| 325 | logger.error("Error in Service-Based MCP message request", t) | ||
| 326 | sendJsonRpcError(response, -32603, "Internal error: " + t.message, null) | ||
| 327 | } finally { | ||
| 328 | ec.destroy() | ||
| 329 | } | ||
| 330 | } | ||
| 331 | |||
| 332 | private void handleGetMessage(HttpServletRequest request, HttpServletResponse response, | ||
| 333 | ExecutionContextImpl ec) throws IOException { | ||
| 334 | |||
| 335 | String sessionId = request.getParameter("sessionId") | ||
| 336 | String acceptHeader = request.getHeader("Accept") | ||
| 337 | |||
| 338 | // If client wants SSE and has sessionId, this is a subscription request | ||
| 339 | if (acceptHeader?.contains("text/event-stream") && sessionId) { | ||
| 340 | // Get Visit directly - this is our session (like EnhancedMcpServlet) | ||
| 341 | def visit = ec.entity.find("moqui.server.Visit") | ||
| 342 | .condition("visitId", sessionId) | ||
| 343 | .one() | ||
| 344 | |||
| 345 | if (visit) { | ||
| 346 | response.setContentType("text/event-stream") | ||
| 347 | response.setCharacterEncoding("UTF-8") | ||
| 348 | response.setHeader("Cache-Control", "no-cache") | ||
| 349 | response.setHeader("Connection", "keep-alive") | ||
| 350 | |||
| 351 | // Send subscription confirmation | ||
| 352 | response.writer.write("event: subscribed\n") | ||
| 353 | response.writer.write("data: {\"type\":\"subscribed\",\"sessionId\":\"${sessionId}\",\"timestamp\":\"${System.currentTimeMillis()}\",\"architecture\":\"Service-based with Visit persistence\"}\n\n") | ||
| 354 | response.writer.flush() | ||
| 355 | } else { | ||
| 356 | response.sendError(HttpServletResponse.SC_NOT_FOUND, "Session not found") | ||
| 357 | } | ||
| 358 | } else { | ||
| 359 | // Return server status | ||
| 360 | response.setContentType("application/json") | ||
| 361 | response.setCharacterEncoding("UTF-8") | ||
| 362 | |||
| 363 | def status = [ | ||
| 364 | serverInfo: [ | ||
| 365 | name: "Moqui Service-Based MCP Server", | ||
| 366 | version: "2.1.0", | ||
| 367 | protocolVersion: "2025-06-18", | ||
| 368 | architecture: "Service-based with Visit persistence" | ||
| 369 | ], | ||
| 370 | connections: [ | ||
| 371 | active: activeSessions.size(), | ||
| 372 | max: maxConnections | ||
| 373 | ], | ||
| 374 | endpoints: [ | ||
| 375 | sse: sseEndpoint, | ||
| 376 | message: messageEndpoint, | ||
| 377 | rpc: "/rpc" | ||
| 378 | ], | ||
| 379 | capabilities: [ | ||
| 380 | tools: true, | ||
| 381 | resources: true, | ||
| 382 | prompts: true, | ||
| 383 | sse: true, | ||
| 384 | jsonRpc: true, | ||
| 385 | services: "McpServices.xml" | ||
| 386 | ] | ||
| 387 | ] | ||
| 388 | |||
| 389 | response.writer.write(groovy.json.JsonOutput.toJson(status)) | ||
| 390 | } | ||
| 391 | } | ||
| 392 | |||
| 393 | private void handlePostMessage(HttpServletRequest request, HttpServletResponse response, | ||
| 394 | ExecutionContextImpl ec) throws IOException { | ||
| 395 | |||
| 396 | // Read and parse JSON-RPC request | ||
| 397 | String requestBody | ||
| 398 | try { | ||
| 399 | BufferedReader reader = request.reader | ||
| 400 | StringBuilder body = new StringBuilder() | ||
| 401 | String line | ||
| 402 | while ((line = reader.readLine()) != null) { | ||
| 403 | body.append(line) | ||
| 404 | } | ||
| 405 | requestBody = body.toString() | ||
| 406 | |||
| 407 | } catch (IOException e) { | ||
| 408 | logger.error("Failed to read request body: ${e.message}") | ||
| 409 | sendJsonRpcError(response, -32700, "Failed to read request body: " + e.message, null) | ||
| 410 | return | ||
| 411 | } | ||
| 412 | |||
| 413 | if (!requestBody) { | ||
| 414 | logger.warn("Empty request body in JSON-RPC POST request") | ||
| 415 | sendJsonRpcError(response, -32602, "Empty request body", null) | ||
| 416 | return | ||
| 417 | } | ||
| 418 | |||
| 419 | def rpcRequest | ||
| 420 | try { | ||
| 421 | rpcRequest = jsonSlurper.parseText(requestBody) | ||
| 422 | } catch (Exception e) { | ||
| 423 | logger.error("Failed to parse JSON-RPC request: ${e.message}") | ||
| 424 | sendJsonRpcError(response, -32700, "Invalid JSON: " + e.message, null) | ||
| 425 | return | ||
| 426 | } | ||
| 427 | |||
| 428 | // Validate JSON-RPC 2.0 basic structure | ||
| 429 | if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) { | ||
| 430 | logger.warn("Invalid JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}") | ||
| 431 | sendJsonRpcError(response, -32600, "Invalid JSON-RPC 2.0 request", rpcRequest?.id) | ||
| 432 | return | ||
| 433 | } | ||
| 434 | |||
| 435 | // Process MCP method by delegating to services | ||
| 436 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, rpcRequest) | ||
| 437 | |||
| 438 | // Build JSON-RPC response | ||
| 439 | def rpcResponse = [ | ||
| 440 | jsonrpc: "2.0", | ||
| 441 | id: rpcRequest.id, | ||
| 442 | result: result | ||
| 443 | ] | ||
| 444 | |||
| 445 | // Send response | ||
| 446 | response.setContentType("application/json") | ||
| 447 | response.setCharacterEncoding("UTF-8") | ||
| 448 | response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse)) | ||
| 449 | } | ||
| 450 | |||
| 451 | private void handleLegacyRpc(HttpServletRequest request, HttpServletResponse response, | ||
| 452 | ExecutionContextFactoryImpl ecfi, String webappName) | ||
| 453 | throws IOException { | ||
| 454 | |||
| 455 | // Legacy support - delegate to existing MoquiMcpServlet logic | ||
| 456 | logger.info("Handling legacy RPC request - redirecting to services") | ||
| 457 | |||
| 458 | // For legacy requests, we can use the same service-based approach | ||
| 459 | ExecutionContextImpl activeEc = ecfi.activeContext.get() | ||
| 460 | if (activeEc != null) { | ||
| 461 | logger.warn("In ServiceBasedMcpServlet.handleLegacyRpc there is already an ExecutionContext for user ${activeEc.user.username}") | ||
| 462 | activeEc.destroy() | ||
| 463 | } | ||
| 464 | |||
| 465 | ExecutionContextImpl ec = ecfi.getEci() | ||
| 466 | |||
| 467 | try { | ||
| 468 | // Initialize web facade for authentication | ||
| 469 | ec.initWebFacade(webappName, request, response) | ||
| 470 | |||
| 471 | // Require authentication - do not fallback to admin | ||
| 472 | if (!ec.user?.userId) { | ||
| 473 | logger.warn("Legacy MCP Request denied - no authenticated user") | ||
| 474 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 475 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) | ||
| 476 | response.setContentType("application/json") | ||
| 477 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 478 | jsonrpc: "2.0", | ||
| 479 | error: [code: -32000, message: "Authentication required. Please provide valid credentials."], | ||
| 480 | id: null | ||
| 481 | ])) | ||
| 482 | return | ||
| 483 | } | ||
| 484 | |||
| 485 | // Read and parse JSON-RPC request (same as POST handling) | ||
| 486 | String requestBody | ||
| 487 | try { | ||
| 488 | BufferedReader reader = request.reader | ||
| 489 | StringBuilder body = new StringBuilder() | ||
| 490 | String line | ||
| 491 | while ((line = reader.readLine()) != null) { | ||
| 492 | body.append(line) | ||
| 493 | } | ||
| 494 | requestBody = body.toString() | ||
| 495 | |||
| 496 | } catch (IOException e) { | ||
| 497 | logger.error("Failed to read legacy RPC request body: ${e.message}") | ||
| 498 | sendJsonRpcError(response, -32700, "Failed to read request body: " + e.message, null) | ||
| 499 | return | ||
| 500 | } | ||
| 501 | |||
| 502 | if (!requestBody) { | ||
| 503 | logger.warn("Empty request body in legacy RPC POST request") | ||
| 504 | sendJsonRpcError(response, -32602, "Empty request body", null) | ||
| 505 | return | ||
| 506 | } | ||
| 507 | |||
| 508 | def rpcRequest | ||
| 509 | try { | ||
| 510 | rpcRequest = jsonSlurper.parseText(requestBody) | ||
| 511 | } catch (Exception e) { | ||
| 512 | logger.error("Failed to parse legacy JSON-RPC request: ${e.message}") | ||
| 513 | sendJsonRpcError(response, -32700, "Invalid JSON: " + e.message, null) | ||
| 514 | return | ||
| 515 | } | ||
| 516 | |||
| 517 | // Validate JSON-RPC 2.0 basic structure | ||
| 518 | if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) { | ||
| 519 | logger.warn("Invalid legacy JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}") | ||
| 520 | sendJsonRpcError(response, -32600, "Invalid JSON-RPC 2.0 request", rpcRequest?.id) | ||
| 521 | return | ||
| 522 | } | ||
| 523 | |||
| 524 | // Process MCP method by delegating to services | ||
| 525 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, rpcRequest) | ||
| 526 | |||
| 527 | // Build JSON-RPC response | ||
| 528 | def rpcResponse = [ | ||
| 529 | jsonrpc: "2.0", | ||
| 530 | id: rpcRequest.id, | ||
| 531 | result: result | ||
| 532 | ] | ||
| 533 | |||
| 534 | // Send response | ||
| 535 | response.setContentType("application/json") | ||
| 536 | response.setCharacterEncoding("UTF-8") | ||
| 537 | response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse)) | ||
| 538 | |||
| 539 | } catch (ArtifactAuthorizationException e) { | ||
| 540 | logger.warn("Legacy MCP Access Forbidden (no authz): " + e.message) | ||
| 541 | sendJsonRpcError(response, -32001, "Access Forbidden: " + e.message, null) | ||
| 542 | } catch (ArtifactTarpitException e) { | ||
| 543 | logger.warn("Legacy MCP Too Many Requests (tarpit): " + e.message) | ||
| 544 | response.setStatus(429) | ||
| 545 | if (e.getRetryAfterSeconds()) { | ||
| 546 | response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) | ||
| 547 | } | ||
| 548 | sendJsonRpcError(response, -32002, "Too Many Requests: " + e.message, null) | ||
| 549 | } catch (Throwable t) { | ||
| 550 | logger.error("Error in legacy MCP message request", t) | ||
| 551 | sendJsonRpcError(response, -32603, "Internal error: " + t.message, null) | ||
| 552 | } finally { | ||
| 553 | ec.destroy() | ||
| 554 | } | ||
| 555 | } | ||
| 556 | |||
| 557 | private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, def rpcRequest) { | ||
| 558 | logger.info("Service-Based METHOD: ${method} with params: ${params}") | ||
| 559 | |||
| 560 | try { | ||
| 561 | switch (method) { | ||
| 562 | case "initialize": | ||
| 563 | return callMcpService("mcp#Initialize", params, ec) | ||
| 564 | case "ping": | ||
| 565 | return callMcpService("mcp#Ping", params, ec) | ||
| 566 | case "tools/list": | ||
| 567 | return callMcpService("mcp#ToolsList", params, ec) | ||
| 568 | case "tools/call": | ||
| 569 | return callMcpService("mcp#ToolsCall", params, ec) | ||
| 570 | case "resources/list": | ||
| 571 | return callMcpService("mcp#ResourcesList", params, ec) | ||
| 572 | case "resources/read": | ||
| 573 | return callMcpService("mcp#ResourcesRead", params, ec) | ||
| 574 | case "notifications/subscribe": | ||
| 575 | return handleSubscription(params, ec, rpcRequest) | ||
| 576 | default: | ||
| 577 | throw new IllegalArgumentException("Unknown MCP method: ${method}") | ||
| 578 | } | ||
| 579 | } catch (Exception e) { | ||
| 580 | logger.error("Error processing Service-Based MCP method ${method}", e) | ||
| 581 | throw e | ||
| 582 | } | ||
| 583 | } | ||
| 584 | |||
| 585 | private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) { | ||
| 586 | logger.info("Service-Based Calling MCP service: ${serviceName} with params: ${params}") | ||
| 587 | |||
| 588 | try { | ||
| 589 | def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}") | ||
| 590 | .parameters(params ?: [:]) | ||
| 591 | .call() | ||
| 592 | |||
| 593 | logger.info("Service-Based MCP service ${serviceName} result: ${result}") | ||
| 594 | return result.result | ||
| 595 | } catch (Exception e) { | ||
| 596 | logger.error("Error calling Service-Based MCP service ${serviceName}", e) | ||
| 597 | throw e | ||
| 598 | } | ||
| 599 | } | ||
| 600 | |||
| 601 | private Map<String, Object> handleSubscription(Map params, ExecutionContextImpl ec, def rpcRequest) { | ||
| 602 | String sessionId = params.sessionId as String | ||
| 603 | String eventType = params.eventType as String | ||
| 604 | |||
| 605 | logger.info("Service-Based Subscription request: sessionId=${sessionId}, eventType=${eventType}") | ||
| 606 | |||
| 607 | VisitBasedMcpSession session = activeSessions.get(sessionId) | ||
| 608 | if (!sessionId || !session || !session.isActive()) { | ||
| 609 | throw new IllegalArgumentException("Invalid or expired session") | ||
| 610 | } | ||
| 611 | |||
| 612 | // Store subscription (in a real implementation, you'd maintain subscription lists) | ||
| 613 | // For now, just confirm subscription | ||
| 614 | |||
| 615 | // Send subscription confirmation via SSE | ||
| 616 | def subscriptionData = [ | ||
| 617 | type: "subscription_confirmed", | ||
| 618 | sessionId: sessionId, | ||
| 619 | eventType: eventType, | ||
| 620 | timestamp: System.currentTimeMillis(), | ||
| 621 | architecture: "Service-based with Visit persistence" | ||
| 622 | ] | ||
| 623 | session.sendMessage(new JsonRpcNotification("subscribed", subscriptionData)) | ||
| 624 | |||
| 625 | return [ | ||
| 626 | subscribed: true, | ||
| 627 | sessionId: sessionId, | ||
| 628 | eventType: eventType, | ||
| 629 | timestamp: System.currentTimeMillis() | ||
| 630 | ] | ||
| 631 | } | ||
| 632 | |||
| 633 | private void sendJsonRpcError(HttpServletResponse response, int code, String message, Object id) throws IOException { | ||
| 634 | response.setStatus(HttpServletResponse.SC_OK) | ||
| 635 | response.setContentType("application/json") | ||
| 636 | response.setCharacterEncoding("UTF-8") | ||
| 637 | |||
| 638 | def errorResponse = [ | ||
| 639 | jsonrpc: "2.0", | ||
| 640 | error: [code: code, message: message], | ||
| 641 | id: id | ||
| 642 | ] | ||
| 643 | |||
| 644 | response.writer.write(groovy.json.JsonOutput.toJson(errorResponse)) | ||
| 645 | } | ||
| 646 | |||
| 647 | private void broadcastSseEvent(String eventType, Map data) { | ||
| 648 | activeSessions.keySet().each { sessionId -> | ||
| 649 | VisitBasedMcpSession session = activeSessions.get(sessionId) | ||
| 650 | if (session && session.isActive()) { | ||
| 651 | try { | ||
| 652 | session.sendMessage(new JsonRpcNotification(eventType, data)) | ||
| 653 | } catch (Exception e) { | ||
| 654 | logger.warn("Failed to send broadcast event to ${sessionId}: ${e.message}") | ||
| 655 | activeSessions.remove(sessionId) | ||
| 656 | } | ||
| 657 | } | ||
| 658 | } | ||
| 659 | } | ||
| 660 | |||
| 661 | private void sendSseEvent(PrintWriter writer, String eventType, String data, long eventId = -1) throws IOException { | ||
| 662 | try { | ||
| 663 | if (eventId >= 0) { | ||
| 664 | writer.write("id: " + eventId + "\n") | ||
| 665 | } | ||
| 666 | writer.write("event: " + eventType + "\n") | ||
| 667 | writer.write("data: " + data + "\n\n") | ||
| 668 | writer.flush() | ||
| 669 | |||
| 670 | if (writer.checkError()) { | ||
| 671 | throw new IOException("Client disconnected") | ||
| 672 | } | ||
| 673 | } catch (Exception e) { | ||
| 674 | throw new IOException("Failed to send SSE event: " + e.message, e) | ||
| 675 | } | ||
| 676 | } | ||
| 677 | |||
| 678 | private void startKeepAliveTask() { | ||
| 679 | executorService.scheduleWithFixedDelay({ | ||
| 680 | try { | ||
| 681 | activeSessions.keySet().each { sessionId -> | ||
| 682 | VisitBasedMcpSession session = activeSessions.get(sessionId) | ||
| 683 | if (session && session.isActive()) { | ||
| 684 | def pingData = [ | ||
| 685 | type: "ping", | ||
| 686 | timestamp: System.currentTimeMillis(), | ||
| 687 | connections: activeSessions.size(), | ||
| 688 | architecture: "Service-based with Visit persistence" | ||
| 689 | ] | ||
| 690 | session.sendMessage(new JsonRpcNotification("ping", pingData)) | ||
| 691 | } else { | ||
| 692 | // Remove inactive session | ||
| 693 | activeSessions.remove(sessionId) | ||
| 694 | } | ||
| 695 | } | ||
| 696 | } catch (Exception e) { | ||
| 697 | logger.warn("Error in Service-Based keep-alive task: ${e.message}") | ||
| 698 | } | ||
| 699 | }, keepAliveIntervalSeconds, keepAliveIntervalSeconds, TimeUnit.SECONDS) | ||
| 700 | } | ||
| 701 | |||
| 702 | |||
| 703 | |||
| 704 | // CORS handling based on MoquiServlet pattern | ||
| 705 | private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) { | ||
| 706 | String originHeader = request.getHeader("Origin") | ||
| 707 | if (originHeader) { | ||
| 708 | response.setHeader("Access-Control-Allow-Origin", originHeader) | ||
| 709 | response.setHeader("Access-Control-Allow-Credentials", "true") | ||
| 710 | } | ||
| 711 | |||
| 712 | String methodHeader = request.getHeader("Access-Control-Request-Method") | ||
| 713 | if (methodHeader) { | ||
| 714 | response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | ||
| 715 | response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept") | ||
| 716 | response.setHeader("Access-Control-Max-Age", "3600") | ||
| 717 | return true | ||
| 718 | } | ||
| 719 | return false | ||
| 720 | } | ||
| 721 | } | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
-
Please register or sign in to post a comment