959cd392 by Ean Schuessler

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
1 parent 339ee0ef
...@@ -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 35
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
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