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
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