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)
Showing
10 changed files
with
842 additions
and
343 deletions
| ... | @@ -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"] | ... | ... |
This diff is collapsed.
Click to expand it.
| 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 | } |
This diff is collapsed.
Click to expand it.
-
Please register or sign in to post a comment