3a39f97b by Ean Schuessler

Refactor MCP servlet to adapter architecture

Introduce clean adapter layer between Moqui infrastructure and MCP protocol:

- transport/MoquiMcpTransport: Interface abstracting transport concerns
- transport/SseTransport: SSE implementation with session management
- adapter/McpSessionAdapter: Maps Moqui Visit to MCP sessions
- adapter/McpToolAdapter: Maps MCP tools/methods to Moqui services
- adapter/MoquiNotificationMcpBridge: Bridges Moqui notifications to MCP

Simplify EnhancedMcpServlet to orchestrator role, removing inline session
management, SSE logic, and tool dispatch. Remove redundant session
validation in Initialize service (MoquiAuthFilter handles auth).

Delete obsolete files:
- VisitBasedMcpSession.groovy (replaced by McpSessionAdapter)
- JsonRpcMessage.groovy (using plain Maps)
- MoquiMcpTransport.groovy (replaced by new interface)
1 parent 9aefba5e
...@@ -37,46 +37,11 @@ ...@@ -37,46 +37,11 @@
37 // Permissions are handled by Moqui's artifact authorization system 37 // Permissions are handled by Moqui's artifact authorization system
38 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group 38 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
39 39
40 // Disable authz to prevent automatic Visit updates during MCP operations 40 // Authentication is handled by MoquiAuthFilter - user context is already set
41 // No need to re-validate session ownership here
41 ec.artifactExecution.disableAuthz() 42 ec.artifactExecution.disableAuthz()
42 43
43 // Get Visit (session) created by servlet and validate access 44 ec.logger.info("MCP Initialize for session ${sessionId}, user ${ec.user.userId}")
44 def visit = ec.entity.find("moqui.server.Visit")
45 .condition("visitId", sessionId)
46 .one()
47
48 if (!visit) {
49 throw new Exception("Invalid session: ${sessionId}")
50 }
51
52 if (visit.userId != ec.user.userId) {
53 throw new Exception("Access denied for session: ${sessionId}")
54 }
55
56 // Update Visit with MCP initialization data
57 UserInfo adminUserInfo = null
58 try {
59 adminUserInfo = ec.user.pushUser("ADMIN")
60 def metadata = [:]
61 try {
62 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
63 } catch (Exception e) {
64 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
65 }
66
67 metadata.mcpInitialized = true
68 metadata.mcpProtocolVersion = protocolVersion
69 metadata.mcpCapabilities = capabilities
70 metadata.mcpClientInfo = clientInfo
71 metadata.mcpInitializedAt = System.currentTimeMillis()
72
73 // Session metadata stored in memory only - no Visit updates to prevent lock contention
74 ec.logger.info("SESSIONID: ${sessionId} - metadata stored in memory")
75 } finally {
76 if (adminUserInfo != null) {
77 ec.user.popUser()
78 }
79 }
80 45
81 // Validate protocol version - support common MCP versions with version negotiation 46 // Validate protocol version - support common MCP versions with version negotiation
82 def supportedVersions = ["2025-11-25", "2025-06-18", "2024-11-05", "2024-10-07", "2023-06-05"] 47 def supportedVersions = ["2025-11-25", "2025-06-18", "2024-11-05", "2024-10-07", "2023-06-05"]
......
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 /**
17 * Simple transport interface for MCP messages
18 */
19 interface MoquiMcpTransport {
20 void sendMessage(JsonRpcMessage message)
21 boolean isActive()
22 String getSessionId()
23 }
...\ 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.moqui.context.ExecutionContext
17 import org.moqui.impl.context.ExecutionContextImpl
18 import org.moqui.entity.EntityValue
19 import org.slf4j.Logger
20 import org.slf4j.LoggerFactory
21
22 import java.util.concurrent.atomic.AtomicBoolean
23 import java.util.concurrent.atomic.AtomicLong
24
25 /**
26 * MCP Session implementation that uses Moqui's Visit entity directly
27 * Eliminates custom session management by leveraging Moqui's built-in Visit system
28 */
29 class VisitBasedMcpSession implements MoquiMcpTransport {
30 protected final static Logger logger = LoggerFactory.getLogger(VisitBasedMcpSession.class)
31
32 private final EntityValue visit // The Visit entity record
33 private final PrintWriter writer
34 private final ExecutionContextImpl ec
35 private final AtomicBoolean active = new AtomicBoolean(true)
36 private final AtomicBoolean closing = new AtomicBoolean(false)
37 private final AtomicLong messageCount = new AtomicLong(0)
38
39 VisitBasedMcpSession(EntityValue visit, PrintWriter writer, ExecutionContextImpl ec) {
40 this.visit = visit
41 this.writer = writer
42 this.ec = ec
43
44 // Initialize MCP session in Visit if not already done
45 initializeMcpSession()
46 }
47
48 private void initializeMcpSession() {
49 try {
50 def metadata = getSessionMetadata()
51 if (!metadata.mcpSession) {
52 // Mark this Visit as an MCP session
53 metadata.mcpSession = true
54 metadata.mcpProtocolVersion = "2025-11-25"
55 metadata.mcpCreatedAt = System.currentTimeMillis()
56 metadata.mcpTransportType = "SSE"
57 metadata.mcpMessageCount = 0
58 saveSessionMetadata(metadata)
59
60 logger.debug("MCP Session initialized for Visit ${visit.visitId}")
61 }
62 } catch (Exception e) {
63 logger.warn("Failed to initialize MCP session for Visit ${visit.visitId}: ${e.message}")
64 }
65 }
66
67 @Override
68 void sendMessage(JsonRpcMessage message) {
69 if (!active.get() || closing.get()) {
70 logger.warn("Attempted to send message on inactive or closing session ${visit.visitId}")
71 return
72 }
73
74 try {
75 String jsonMessage = message.toJson()
76 sendSseEvent("message", jsonMessage)
77 messageCount.incrementAndGet()
78
79 // Session activity now managed at servlet level to avoid lock contention
80
81 } catch (Exception e) {
82 logger.error("Failed to send message on session ${visit.visitId}: ${e.message}")
83 if (e.message?.contains("disconnected") || e.message?.contains("Client disconnected")) {
84 close()
85 }
86 }
87 }
88
89 void closeGracefully() {
90 if (!active.compareAndSet(true, false)) {
91 return // Already closed
92 }
93
94 closing.set(true)
95 logger.debug("Gracefully closing MCP session ${visit.visitId}")
96
97 try {
98 // Send graceful shutdown notification
99 def shutdownMessage = new JsonRpcNotification("shutdown", [
100 sessionId: visit.visitId,
101 timestamp: System.currentTimeMillis()
102 ])
103 sendMessage(shutdownMessage)
104
105 // Give some time for message to be sent
106 Thread.sleep(100)
107
108 } catch (Exception e) {
109 logger.warn("Error during graceful shutdown of session ${visit.visitId}: ${e.message}")
110 } finally {
111 close()
112 }
113 }
114
115 void close() {
116 if (!active.compareAndSet(true, false)) {
117 return // Already closed
118 }
119
120 logger.debug("Closing MCP session ${visit.visitId} (messages sent: ${messageCount.get()})")
121
122 try {
123 // Send final close event if writer is still available
124 if (writer && !writer.checkError()) {
125 sendSseEvent("close", groovy.json.JsonOutput.toJson([
126 type: "disconnected",
127 sessionId: visit.visitId,
128 messageCount: messageCount.get(),
129 timestamp: System.currentTimeMillis()
130 ]))
131 }
132
133 } catch (Exception e) {
134 logger.warn("Error during session close ${visit.visitId}: ${e.message}")
135 }
136 }
137
138 @Override
139 boolean isActive() {
140 return active.get() && !closing.get() && writer && !writer.checkError()
141 }
142
143 @Override
144 String getSessionId() {
145 return visit.visitId
146 }
147
148 String getVisitId() {
149 return visit.visitId
150 }
151
152 EntityValue getVisit() {
153 return visit
154 }
155
156 /**
157 * Get session statistics
158 */
159 Map getSessionStats() {
160 return [
161 sessionId: visit.visitId,
162 visitId: visit.visitId,
163 createdAt: visit.fromDate,
164 messageCount: messageCount.get(),
165 active: active.get(),
166 closing: closing.get(),
167 duration: System.currentTimeMillis() - visit.fromDate.time
168 ]
169 }
170
171 /**
172 * Send SSE event with proper formatting
173 */
174 private void sendSseEvent(String eventType, String data) throws IOException {
175 if (!writer || writer.checkError()) {
176 throw new IOException("Writer is closed or client disconnected")
177 }
178
179 writer.write("event: " + eventType + "\n")
180 writer.write("data: " + data + "\n\n")
181 writer.flush()
182
183 if (writer.checkError()) {
184 throw new IOException("Client disconnected during write")
185 }
186 }
187
188 // Session activity management moved to servlet level to avoid database lock contention
189 // This method is no longer called - servlet manages session updates throttled
190
191 // Session end management moved to servlet level to avoid database lock contention
192 // Servlet will handle Visit updates when connections close
193
194 /**
195 * Get session metadata from Visit's initialRequest field
196 */
197 Map getSessionMetadata() {
198 try {
199 def metadataJson = visit.initialRequest
200 if (metadataJson) {
201 return groovy.json.JsonSlurper().parseText(metadataJson) as Map
202 }
203 } catch (Exception e) {
204 logger.debug("Failed to parse session metadata: ${e.message}")
205 }
206 return [:]
207 }
208
209 /**
210 * Add custom metadata to session
211 */
212 void addSessionMetadata(String key, Object value) {
213 def metadata = getSessionMetadata()
214 metadata[key] = value
215 saveSessionMetadata(metadata)
216 }
217
218 /**
219 * Save session metadata to Visit's initialRequest field
220 */
221 private void saveSessionMetadata(Map metadata) {
222 // Session metadata stored in memory only - no Visit updates to prevent lock contention
223 try {
224 sessionMetadata.putAll(metadata)
225 } catch (Exception e) {
226 logger.debug("Failed to save session metadata: ${e.message}")
227 }
228 }
229 }
...\ 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.adapter
15
16 import org.moqui.entity.EntityValue
17 import org.slf4j.Logger
18 import org.slf4j.LoggerFactory
19
20 import java.util.concurrent.ConcurrentHashMap
21
22 /**
23 * Adapter that maps Moqui Visit sessions to MCP sessions.
24 * Provides in-memory session tracking to avoid database lock contention.
25 */
26 class McpSessionAdapter {
27 protected final static Logger logger = LoggerFactory.getLogger(McpSessionAdapter.class)
28
29 // Visit ID → MCP Session state
30 private final Map<String, McpSession> sessions = new ConcurrentHashMap<>()
31
32 // User ID → Set of Visit IDs (for user-targeted notifications)
33 private final Map<String, Set<String>> userSessions = new ConcurrentHashMap<>()
34
35 // Session-specific locks to avoid sessionId.intern() deadlocks
36 private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>()
37
38 /**
39 * Create a new MCP session from a Moqui Visit
40 * @param visit The Moqui Visit entity
41 * @return The created McpSession
42 */
43 McpSession createSession(EntityValue visit) {
44 String visitId = visit.visitId?.toString()
45 String userId = visit.userId?.toString()
46
47 if (!visitId) {
48 throw new IllegalArgumentException("Visit must have a visitId")
49 }
50
51 def session = new McpSession(
52 visitId: visitId,
53 userId: userId,
54 state: McpSession.STATE_INITIALIZED
55 )
56 sessions.put(visitId, session)
57
58 // Track user → sessions mapping
59 if (userId) {
60 def userSet = userSessions.computeIfAbsent(userId) { new ConcurrentHashMap<>().newKeySet() }
61 userSet.add(visitId)
62 }
63
64 logger.debug("Created MCP session ${visitId} for user ${userId}")
65 return session
66 }
67
68 /**
69 * Create a new MCP session with explicit parameters
70 * @param visitId The Visit/session ID
71 * @param userId The user ID
72 * @return The created McpSession
73 */
74 McpSession createSession(String visitId, String userId) {
75 if (!visitId) {
76 throw new IllegalArgumentException("visitId is required")
77 }
78
79 def session = new McpSession(
80 visitId: visitId,
81 userId: userId,
82 state: McpSession.STATE_INITIALIZED
83 )
84 sessions.put(visitId, session)
85
86 // Track user → sessions mapping
87 if (userId) {
88 def userSet = userSessions.computeIfAbsent(userId) { new ConcurrentHashMap<>().newKeySet() }
89 userSet.add(visitId)
90 }
91
92 logger.debug("Created MCP session ${visitId} for user ${userId}")
93 return session
94 }
95
96 /**
97 * Close and remove a session
98 * @param visitId The session/visit ID to close
99 */
100 void closeSession(String visitId) {
101 def session = sessions.remove(visitId)
102 if (session) {
103 // Remove from user tracking
104 if (session.userId) {
105 def userSet = userSessions.get(session.userId)
106 if (userSet) {
107 userSet.remove(visitId)
108 if (userSet.isEmpty()) {
109 userSessions.remove(session.userId)
110 }
111 }
112 }
113 // Clean up session lock
114 sessionLocks.remove(visitId)
115 logger.debug("Closed MCP session ${visitId}")
116 }
117 }
118
119 /**
120 * Get a session by visit ID
121 * @param visitId The session/visit ID
122 * @return The McpSession or null if not found
123 */
124 McpSession getSession(String visitId) {
125 return sessions.get(visitId)
126 }
127
128 /**
129 * Check if a session exists and is active
130 * @param visitId The session/visit ID
131 * @return true if the session exists
132 */
133 boolean hasSession(String visitId) {
134 return sessions.containsKey(visitId)
135 }
136
137 /**
138 * Get all session IDs for a specific user
139 * @param userId The user ID
140 * @return Set of session/visit IDs (empty set if none)
141 */
142 Set<String> getSessionsForUser(String userId) {
143 return userSessions.get(userId) ?: Collections.emptySet()
144 }
145
146 /**
147 * Get all active session IDs
148 * @return Set of all session IDs
149 */
150 Set<String> getAllSessionIds() {
151 return sessions.keySet()
152 }
153
154 /**
155 * Get the count of active sessions
156 * @return Number of active sessions
157 */
158 int getSessionCount() {
159 return sessions.size()
160 }
161
162 /**
163 * Get a session-specific lock for synchronized operations
164 * @param visitId The session/visit ID
165 * @return The lock object
166 */
167 Object getSessionLock(String visitId) {
168 return sessionLocks.computeIfAbsent(visitId) { new Object() }
169 }
170
171 /**
172 * Update session state
173 * @param visitId The session/visit ID
174 * @param state The new state
175 */
176 void setSessionState(String visitId, int state) {
177 def session = sessions.get(visitId)
178 if (session) {
179 session.state = state
180 logger.debug("Session ${visitId} state changed to ${state}")
181 }
182 }
183
184 /**
185 * Update session activity timestamp
186 * @param visitId The session/visit ID
187 */
188 void touchSession(String visitId) {
189 def session = sessions.get(visitId)
190 if (session) {
191 session.touch()
192 }
193 }
194
195 /**
196 * Get session statistics for monitoring
197 * @return Map of session statistics
198 */
199 Map getStatistics() {
200 return [
201 totalSessions: sessions.size(),
202 usersWithSessions: userSessions.size(),
203 sessionsPerUser: userSessions.collectEntries { userId, sessionSet ->
204 [(userId): sessionSet.size()]
205 }
206 ]
207 }
208 }
209
210 /**
211 * Represents an MCP session state
212 */
213 class McpSession {
214 static final int STATE_UNINITIALIZED = 0
215 static final int STATE_INITIALIZING = 1
216 static final int STATE_INITIALIZED = 2
217
218 String visitId
219 String userId
220 int state = STATE_UNINITIALIZED
221 long lastActivity = System.currentTimeMillis()
222 long createdAt = System.currentTimeMillis()
223
224 // SSE writer reference (for active connections)
225 PrintWriter sseWriter
226
227 // Notification queue for this session
228 List<Map> notificationQueue = Collections.synchronizedList(new ArrayList<>())
229
230 // Subscriptions (method names this session is subscribed to)
231 Set<String> subscriptions = Collections.newSetFromMap(new ConcurrentHashMap<>())
232
233 void touch() {
234 lastActivity = System.currentTimeMillis()
235 }
236
237 boolean isActive() {
238 return state == STATE_INITIALIZED && sseWriter != null && !sseWriter.checkError()
239 }
240
241 boolean hasActiveWriter() {
242 return sseWriter != null && !sseWriter.checkError()
243 }
244
245 long getDurationMs() {
246 return System.currentTimeMillis() - createdAt
247 }
248
249 Map toMap() {
250 return [
251 visitId: visitId,
252 userId: userId,
253 state: state,
254 lastActivity: lastActivity,
255 createdAt: createdAt,
256 durationMs: getDurationMs(),
257 active: isActive(),
258 hasWriter: sseWriter != null,
259 queuedNotifications: notificationQueue.size(),
260 subscriptions: subscriptions.toList()
261 ]
262 }
263 }
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.adapter
15
16 import org.moqui.context.ExecutionContext
17 import org.slf4j.Logger
18 import org.slf4j.LoggerFactory
19
20 /**
21 * Adapter that maps MCP tool calls to Moqui services.
22 * Provides a clean translation layer between MCP protocol and Moqui service framework.
23 */
24 class McpToolAdapter {
25 protected final static Logger logger = LoggerFactory.getLogger(McpToolAdapter.class)
26
27 // MCP tool name → Moqui service name mapping
28 private static final Map<String, String> TOOL_SERVICE_MAP = [
29 'moqui_browse_screens': 'McpServices.mcp#BrowseScreens',
30 'moqui_search_screens': 'McpServices.mcp#SearchScreens',
31 'moqui_get_screen_details': 'McpServices.mcp#GetScreenDetails',
32 'moqui_get_help': 'McpServices.mcp#GetHelp'
33 ]
34
35 // MCP method → Moqui service name mapping for JSON-RPC methods
36 private static final Map<String, String> METHOD_SERVICE_MAP = [
37 'initialize': 'McpServices.mcp#Initialize',
38 'ping': 'McpServices.mcp#Ping',
39 'tools/list': 'McpServices.list#Tools',
40 'tools/call': 'McpServices.mcp#ToolsCall',
41 'resources/list': 'McpServices.mcp#ResourcesList',
42 'resources/read': 'McpServices.mcp#ResourcesRead',
43 'resources/templates/list': 'McpServices.mcp#ResourcesTemplatesList',
44 'resources/subscribe': 'McpServices.mcp#ResourcesSubscribe',
45 'resources/unsubscribe': 'McpServices.mcp#ResourcesUnsubscribe',
46 'prompts/list': 'McpServices.mcp#PromptsList',
47 'prompts/get': 'McpServices.mcp#PromptsGet',
48 'roots/list': 'McpServices.mcp#RootsList',
49 'sampling/createMessage': 'McpServices.mcp#SamplingCreateMessage',
50 'elicitation/create': 'McpServices.mcp#ElicitationCreate'
51 ]
52
53 // Tool descriptions for MCP tool definitions
54 private static final Map<String, String> TOOL_DESCRIPTIONS = [
55 'moqui_browse_screens': 'Browse Moqui screen hierarchy and render screen content',
56 'moqui_search_screens': 'Search for screens by name to find their paths',
57 'moqui_get_screen_details': 'Get screen field details including dropdown options',
58 'moqui_get_help': 'Fetch extended documentation for a screen or service'
59 ]
60
61 /**
62 * Call an MCP tool, translating to the appropriate Moqui service
63 * @param ec The execution context
64 * @param toolName The MCP tool name
65 * @param arguments The tool arguments
66 * @return The result map or error map
67 */
68 Map callTool(ExecutionContext ec, String toolName, Map arguments) {
69 String serviceName = TOOL_SERVICE_MAP.get(toolName)
70 if (!serviceName) {
71 logger.warn("Unknown tool: ${toolName}")
72 return [error: [code: -32601, message: "Unknown tool: ${toolName}"]]
73 }
74
75 logger.debug("Calling tool ${toolName} -> service ${serviceName} with args: ${arguments}")
76
77 try {
78 ec.artifactExecution.disableAuthz()
79 def result = ec.service.sync()
80 .name(serviceName)
81 .parameters(arguments ?: [:])
82 .call()
83
84 logger.debug("Tool ${toolName} completed successfully")
85
86 // Extract result from service response if wrapped
87 if (result?.containsKey('result')) {
88 return result.result
89 }
90 return result ?: [:]
91
92 } catch (Exception e) {
93 logger.error("Error calling tool ${toolName}: ${e.message}", e)
94 return [error: [code: -32000, message: e.message]]
95 } finally {
96 ec.artifactExecution.enableAuthz()
97 }
98 }
99
100 /**
101 * Call an MCP method, translating to the appropriate Moqui service
102 * @param ec The execution context
103 * @param method The MCP method name
104 * @param params The method parameters
105 * @return The result map or error map
106 */
107 Map callMethod(ExecutionContext ec, String method, Map params) {
108 String serviceName = METHOD_SERVICE_MAP.get(method)
109 if (!serviceName) {
110 logger.warn("Unknown method: ${method}")
111 return [error: [code: -32601, message: "Method not found: ${method}"]]
112 }
113
114 logger.debug("Calling method ${method} -> service ${serviceName}")
115
116 try {
117 ec.artifactExecution.disableAuthz()
118 def result = ec.service.sync()
119 .name(serviceName)
120 .parameters(params ?: [:])
121 .call()
122
123 logger.debug("Method ${method} completed successfully")
124
125 // Extract result from service response if wrapped
126 if (result?.containsKey('result')) {
127 return result.result
128 }
129 return result ?: [:]
130
131 } catch (Exception e) {
132 logger.error("Error calling method ${method}: ${e.message}", e)
133 return [error: [code: -32603, message: "Internal error: ${e.message}"]]
134 } finally {
135 ec.artifactExecution.enableAuthz()
136 }
137 }
138
139 /**
140 * Check if a tool name is valid
141 * @param toolName The tool name to check
142 * @return true if the tool is known
143 */
144 boolean isValidTool(String toolName) {
145 return TOOL_SERVICE_MAP.containsKey(toolName)
146 }
147
148 /**
149 * Check if a method name is valid (has a service mapping)
150 * @param method The method name to check
151 * @return true if the method has a service mapping
152 */
153 boolean isValidMethod(String method) {
154 return METHOD_SERVICE_MAP.containsKey(method)
155 }
156
157 /**
158 * Get the service name for a given tool
159 * @param toolName The tool name
160 * @return The service name or null if not found
161 */
162 String getServiceForTool(String toolName) {
163 return TOOL_SERVICE_MAP.get(toolName)
164 }
165
166 /**
167 * Get the service name for a given method
168 * @param method The method name
169 * @return The service name or null if not found
170 */
171 String getServiceForMethod(String method) {
172 return METHOD_SERVICE_MAP.get(method)
173 }
174
175 /**
176 * Get the list of available tools with their definitions
177 * @return List of tool definition maps
178 */
179 List<Map> listTools() {
180 return TOOL_SERVICE_MAP.keySet().collect { toolName ->
181 [
182 name: toolName,
183 description: TOOL_DESCRIPTIONS.get(toolName) ?: "MCP tool: ${toolName}",
184 serviceName: TOOL_SERVICE_MAP.get(toolName)
185 ]
186 }
187 }
188
189 /**
190 * Get tool description
191 * @param toolName The tool name
192 * @return The tool description or null if not found
193 */
194 String getToolDescription(String toolName) {
195 return TOOL_DESCRIPTIONS.get(toolName)
196 }
197
198 /**
199 * Get all supported tool names
200 * @return Set of tool names
201 */
202 Set<String> getToolNames() {
203 return TOOL_SERVICE_MAP.keySet()
204 }
205
206 /**
207 * Get all supported method names
208 * @return Set of method names
209 */
210 Set<String> getMethodNames() {
211 return METHOD_SERVICE_MAP.keySet()
212 }
213 }
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.adapter
15
16 import org.moqui.context.ExecutionContextFactory
17 import org.moqui.context.NotificationMessage
18 import org.moqui.context.NotificationMessageListener
19 import org.moqui.mcp.transport.MoquiMcpTransport
20 import org.slf4j.Logger
21 import org.slf4j.LoggerFactory
22
23 /**
24 * Bridge that connects Moqui's NotificationMessage system to MCP notifications.
25 * Implements NotificationMessageListener to receive all Moqui notifications
26 * and forwards them to MCP clients via the transport layer.
27 */
28 class MoquiNotificationMcpBridge implements NotificationMessageListener {
29 protected final static Logger logger = LoggerFactory.getLogger(MoquiNotificationMcpBridge.class)
30
31 private ExecutionContextFactory ecf
32 private MoquiMcpTransport transport
33
34 // Topic prefix for MCP-specific notifications (optional filtering)
35 private static final String MCP_TOPIC_PREFIX = "mcp."
36
37 // Whether to forward all notifications or only MCP-prefixed ones
38 private boolean forwardAllNotifications = true
39
40 /**
41 * Initialize the bridge with the ECF and transport
42 * Note: This method signature matches what the ECF registration expects
43 */
44 @Override
45 void init(ExecutionContextFactory ecf) {
46 this.ecf = ecf
47 logger.info("MoquiNotificationMcpBridge initialized (transport not yet set)")
48 }
49
50 /**
51 * Set the transport after initialization
52 * @param transport The MCP transport to use for sending notifications
53 */
54 void setTransport(MoquiMcpTransport transport) {
55 this.transport = transport
56 logger.info("MoquiNotificationMcpBridge transport configured: ${transport?.class?.simpleName}")
57 }
58
59 /**
60 * Configure whether to forward all notifications or only MCP-prefixed ones
61 * @param forwardAll If true, forward all notifications; if false, only forward those with topic starting with 'mcp.'
62 */
63 void setForwardAllNotifications(boolean forwardAll) {
64 this.forwardAllNotifications = forwardAll
65 logger.info("MoquiNotificationMcpBridge forwardAllNotifications set to: ${forwardAll}")
66 }
67
68 @Override
69 void onMessage(NotificationMessage nm) {
70 if (transport == null) {
71 logger.trace("Transport not configured, skipping notification: ${nm.topic}")
72 return
73 }
74
75 // Optionally filter by topic prefix
76 if (!forwardAllNotifications && !nm.topic?.startsWith(MCP_TOPIC_PREFIX)) {
77 logger.trace("Skipping non-MCP notification: ${nm.topic}")
78 return
79 }
80
81 try {
82 // Convert Moqui notification → MCP notification format
83 Map mcpNotification = convertToMcpNotification(nm)
84
85 // Get target users
86 Set<String> notifyUserIds = nm.getNotifyUserIds()
87
88 if (notifyUserIds && !notifyUserIds.isEmpty()) {
89 // Send to each target user's active MCP sessions
90 int sentCount = 0
91 for (String userId in notifyUserIds) {
92 try {
93 transport.sendNotificationToUser(userId, mcpNotification)
94 sentCount++
95 logger.debug("Sent MCP notification to user ${userId}: ${nm.topic}")
96 } catch (Exception e) {
97 logger.warn("Failed to send MCP notification to user ${userId}: ${e.message}")
98 }
99 }
100 logger.info("Forwarded Moqui notification '${nm.topic}' to ${sentCount} users via MCP")
101 } else {
102 // No specific users, could broadcast or log
103 logger.debug("Notification '${nm.topic}' has no target users, skipping MCP forward")
104 }
105
106 } catch (Exception e) {
107 logger.error("Error converting/sending Moqui notification to MCP: ${e.message}", e)
108 }
109 }
110
111 /**
112 * Convert a Moqui NotificationMessage to MCP notification format
113 * @param nm The Moqui notification
114 * @return The MCP notification map
115 */
116 private Map convertToMcpNotification(NotificationMessage nm) {
117 return [
118 jsonrpc: "2.0",
119 method: "notifications/message",
120 params: [
121 topic: nm.topic,
122 subTopic: nm.subTopic,
123 title: nm.title,
124 type: nm.type,
125 message: nm.getMessageMap() ?: [:],
126 link: nm.link,
127 showAlert: nm.isShowAlert(),
128 notificationMessageId: nm.notificationMessageId,
129 timestamp: System.currentTimeMillis()
130 ]
131 ]
132 }
133
134 /**
135 * Create a custom MCP notification and send to specific users
136 * @param topic The notification topic
137 * @param title The notification title
138 * @param message The message content
139 * @param userIds The target user IDs
140 */
141 void sendMcpNotification(String topic, String title, Map message, Set<String> userIds) {
142 if (transport == null) {
143 logger.warn("Cannot send MCP notification: transport not configured")
144 return
145 }
146
147 Map mcpNotification = [
148 jsonrpc: "2.0",
149 method: "notifications/message",
150 params: [
151 topic: topic,
152 title: title,
153 message: message,
154 timestamp: System.currentTimeMillis()
155 ]
156 ]
157
158 for (String userId in userIds) {
159 try {
160 transport.sendNotificationToUser(userId, mcpNotification)
161 logger.debug("Sent custom MCP notification to user ${userId}: ${topic}")
162 } catch (Exception e) {
163 logger.warn("Failed to send custom MCP notification to user ${userId}: ${e.message}")
164 }
165 }
166 }
167
168 /**
169 * Broadcast an MCP notification to all active sessions
170 * @param topic The notification topic
171 * @param title The notification title
172 * @param message The message content
173 */
174 void broadcastMcpNotification(String topic, String title, Map message) {
175 if (transport == null) {
176 logger.warn("Cannot broadcast MCP notification: transport not configured")
177 return
178 }
179
180 Map mcpNotification = [
181 jsonrpc: "2.0",
182 method: "notifications/message",
183 params: [
184 topic: topic,
185 title: title,
186 message: message,
187 timestamp: System.currentTimeMillis()
188 ]
189 ]
190
191 try {
192 transport.broadcastNotification(mcpNotification)
193 logger.info("Broadcast MCP notification: ${topic}")
194 } catch (Exception e) {
195 logger.error("Failed to broadcast MCP notification: ${e.message}", e)
196 }
197 }
198
199 /**
200 * Send a tools/list_changed notification to inform clients that available tools have changed
201 */
202 void notifyToolsChanged() {
203 if (transport == null) {
204 logger.warn("Cannot send tools changed notification: transport not configured")
205 return
206 }
207
208 Map notification = [
209 jsonrpc: "2.0",
210 method: "notifications/tools/list_changed",
211 params: [:]
212 ]
213
214 try {
215 transport.broadcastNotification(notification)
216 logger.info("Broadcast tools/list_changed notification")
217 } catch (Exception e) {
218 logger.error("Failed to broadcast tools changed notification: ${e.message}", e)
219 }
220 }
221
222 /**
223 * Send a resources/list_changed notification
224 */
225 void notifyResourcesChanged() {
226 if (transport == null) return
227
228 Map notification = [
229 jsonrpc: "2.0",
230 method: "notifications/resources/list_changed",
231 params: [:]
232 ]
233
234 try {
235 transport.broadcastNotification(notification)
236 logger.info("Broadcast resources/list_changed notification")
237 } catch (Exception e) {
238 logger.error("Failed to broadcast resources changed notification: ${e.message}", e)
239 }
240 }
241
242 /**
243 * Send a progress notification for a long-running operation
244 * @param sessionId The target session
245 * @param progressToken The progress token
246 * @param progress Current progress value
247 * @param total Total progress value (optional)
248 */
249 void sendProgressNotification(String sessionId, String progressToken, Number progress, Number total = null) {
250 if (transport == null) return
251
252 Map notification = [
253 jsonrpc: "2.0",
254 method: "notifications/progress",
255 params: [
256 progressToken: progressToken,
257 progress: progress,
258 total: total
259 ]
260 ]
261
262 try {
263 transport.sendNotification(sessionId, notification)
264 logger.debug("Sent progress notification to session ${sessionId}: ${progress}/${total ?: '?'}")
265 } catch (Exception e) {
266 logger.warn("Failed to send progress notification: ${e.message}")
267 }
268 }
269
270 @Override
271 void destroy() {
272 logger.info("MoquiNotificationMcpBridge destroyed")
273 this.ecf = null
274 this.transport = null
275 }
276 }
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.transport
15
16 /**
17 * Transport interface for MCP messages.
18 * Abstracts transport concerns so implementations can be swapped (SSE, WebSocket, etc.)
19 */
20 interface MoquiMcpTransport {
21
22 // Session lifecycle
23
24 /**
25 * Open a new MCP session for the given user
26 * @param sessionId The session ID (typically Visit ID)
27 * @param userId The user ID associated with this session
28 */
29 void openSession(String sessionId, String userId)
30
31 /**
32 * Close an existing MCP session
33 * @param sessionId The session ID to close
34 */
35 void closeSession(String sessionId)
36
37 /**
38 * Check if a session is currently active
39 * @param sessionId The session ID to check
40 * @return true if the session is active
41 */
42 boolean isSessionActive(String sessionId)
43
44 // Message sending
45
46 /**
47 * Send a JSON-RPC message to a specific session
48 * @param sessionId The target session ID
49 * @param message The message to send (will be JSON-serialized)
50 */
51 void sendMessage(String sessionId, Map message)
52
53 /**
54 * Send an MCP notification to a specific session
55 * @param sessionId The target session ID
56 * @param notification The notification to send
57 */
58 void sendNotification(String sessionId, Map notification)
59
60 /**
61 * Send an MCP notification to all sessions for a specific user
62 * @param userId The target user ID
63 * @param notification The notification to send
64 */
65 void sendNotificationToUser(String userId, Map notification)
66
67 // Broadcast
68
69 /**
70 * Broadcast a notification to all active sessions
71 * @param notification The notification to broadcast
72 */
73 void broadcastNotification(Map notification)
74
75 /**
76 * Get the number of active sessions
77 * @return count of active sessions
78 */
79 int getActiveSessionCount()
80
81 /**
82 * Get session IDs for a specific user
83 * @param userId The user ID
84 * @return Set of session IDs for this user
85 */
86 Set<String> getSessionsForUser(String userId)
87 }