Refactor MCP implementation with enhanced security and session management
- Replace MoquiMcpServlet with EnhancedMcpServlet for better SSE handling - Add proper JSON-RPC message classes for MCP compatibility - Implement proper permission checks in ToolsList service - Remove temporary permission bypasses and test ping service - Update McpFilter to use EnhancedMcpServlet - Clean up unused dependencies and configuration files - Fix parameter type handling and required field detection
Showing
11 changed files
with
93 additions
and
1155 deletions
lib/moqui-framework-3.1.0-rc2.jar
deleted
100644 → 0
No preview for this file type
| ... | @@ -231,7 +231,7 @@ | ... | @@ -231,7 +231,7 @@ |
| 231 | </actions> | 231 | </actions> |
| 232 | </service> | 232 | </service> |
| 233 | 233 | ||
| 234 | <service verb="mcp" noun="Initialize" authenticate="false" allow-remote="true" transaction-timeout="30"> | 234 | <service verb="mcp" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30"> |
| 235 | <description>Handle MCP initialize request using Moqui authentication</description> | 235 | <description>Handle MCP initialize request using Moqui authentication</description> |
| 236 | <in-parameters> | 236 | <in-parameters> |
| 237 | <parameter name="protocolVersion" required="true"/> | 237 | <parameter name="protocolVersion" required="true"/> |
| ... | @@ -318,14 +318,12 @@ | ... | @@ -318,14 +318,12 @@ |
| 318 | continue | 318 | continue |
| 319 | } | 319 | } |
| 320 | 320 | ||
| 321 | // TODO: Fix permission check - temporarily bypass for testing | 321 | // Check permission using Moqui's artifact authorization |
| 322 | boolean hasPermission = true | 322 | boolean hasPermission = ec.user.hasPermission(serviceName) |
| 323 | ec.logger.info("MCP ToolsList: Service ${serviceName} bypassing permission check for testing") | 323 | ec.logger.info("MCP ToolsList: Service ${serviceName} hasPermission=${hasPermission}") |
| 324 | // boolean hasPermission = ec.user.hasPermission(serviceName) | 324 | if (!hasPermission) { |
| 325 | // ec.logger.info("MCP ToolsList: Service ${serviceName} hasPermission=${hasPermission}") | 325 | continue |
| 326 | // if (!hasPermission) { | 326 | } |
| 327 | // continue | ||
| 328 | // } | ||
| 329 | 327 | ||
| 330 | def serviceDefinition = ec.service.getServiceDefinition(serviceName) | 328 | def serviceDefinition = ec.service.getServiceDefinition(serviceName) |
| 331 | if (!serviceDefinition) continue | 329 | if (!serviceDefinition) continue |
| ... | @@ -363,6 +361,7 @@ | ... | @@ -363,6 +361,7 @@ |
| 363 | } | 361 | } |
| 364 | 362 | ||
| 365 | // Convert Moqui type to JSON Schema type | 363 | // Convert Moqui type to JSON Schema type |
| 364 | // Convert Moqui type to JSON Schema type | ||
| 366 | def typeMap = [ | 365 | def typeMap = [ |
| 367 | "text-short": "string", | 366 | "text-short": "string", |
| 368 | "text-medium": "string", | 367 | "text-medium": "string", |
| ... | @@ -379,14 +378,14 @@ | ... | @@ -379,14 +378,14 @@ |
| 379 | "boolean": "boolean", | 378 | "boolean": "boolean", |
| 380 | "text-indicator": "boolean" | 379 | "text-indicator": "boolean" |
| 381 | ] | 380 | ] |
| 382 | def jsonSchemaType = typeMap[paramInfo.type] ?: "string" | 381 | def jsonSchemaType = typeMap[paramType] ?: "string" |
| 383 | 382 | ||
| 384 | tool.inputSchema.properties[paramName] = [ | 383 | tool.inputSchema.properties[paramName] = [ |
| 385 | type: jsonSchemaType, | 384 | type: jsonSchemaType, |
| 386 | description: paramDesc | 385 | description: paramDesc |
| 387 | ] | 386 | ] |
| 388 | 387 | ||
| 389 | if (paramInfo.required) { | 388 | if (paramNode?.attribute('required') == "true") { |
| 390 | tool.inputSchema.required << paramName | 389 | tool.inputSchema.required << paramName |
| 391 | } | 390 | } |
| 392 | } | 391 | } |
| ... | @@ -815,17 +814,7 @@ | ... | @@ -815,17 +814,7 @@ |
| 815 | </actions> | 814 | </actions> |
| 816 | </service> | 815 | </service> |
| 817 | 816 | ||
| 818 | <service verb="mcp" noun="Ping" authenticate="false" allow-remote="true" transaction-timeout="30"> | 817 | |
| 819 | <description>Simple ping service for MCP testing</description> | ||
| 820 | <out-parameters> | ||
| 821 | <parameter name="message" type="String"/> | ||
| 822 | </out-parameters> | ||
| 823 | <actions> | ||
| 824 | <script><![CDATA[ | ||
| 825 | result = [message: "MCP ping successful at ${new Date()}"] | ||
| 826 | ]]></script> | ||
| 827 | </actions> | ||
| 828 | </service> | ||
| 829 | 818 | ||
| 830 | <!-- NOTE: handle#McpRequest service removed - functionality moved to screen/webapp.xml for unified handling --> | 819 | <!-- NOTE: handle#McpRequest service removed - functionality moved to screen/webapp.xml for unified handling --> |
| 831 | 820 | ... | ... |
| ... | @@ -14,6 +14,7 @@ | ... | @@ -14,6 +14,7 @@ |
| 14 | package org.moqui.mcp | 14 | package org.moqui.mcp |
| 15 | 15 | ||
| 16 | import groovy.json.JsonSlurper | 16 | import groovy.json.JsonSlurper |
| 17 | import groovy.json.JsonOutput | ||
| 17 | import org.moqui.impl.context.ExecutionContextFactoryImpl | 18 | import org.moqui.impl.context.ExecutionContextFactoryImpl |
| 18 | import org.moqui.context.ArtifactAuthorizationException | 19 | import org.moqui.context.ArtifactAuthorizationException |
| 19 | import org.moqui.context.ArtifactTarpitException | 20 | import org.moqui.context.ArtifactTarpitException |
| ... | @@ -31,6 +32,47 @@ import java.util.concurrent.atomic.AtomicBoolean | ... | @@ -31,6 +32,47 @@ import java.util.concurrent.atomic.AtomicBoolean |
| 31 | import java.util.UUID | 32 | import java.util.UUID |
| 32 | 33 | ||
| 33 | /** | 34 | /** |
| 35 | * Simple JSON-RPC Message classes for MCP compatibility | ||
| 36 | */ | ||
| 37 | class JsonRpcMessage { | ||
| 38 | String jsonrpc = "2.0" | ||
| 39 | } | ||
| 40 | |||
| 41 | class JsonRpcResponse extends JsonRpcMessage { | ||
| 42 | Object id | ||
| 43 | Object result | ||
| 44 | Map error | ||
| 45 | |||
| 46 | JsonRpcResponse(Object result, Object id) { | ||
| 47 | this.result = result | ||
| 48 | this.id = id | ||
| 49 | } | ||
| 50 | |||
| 51 | JsonRpcResponse(Map error, Object id) { | ||
| 52 | this.error = error | ||
| 53 | this.id = id | ||
| 54 | } | ||
| 55 | |||
| 56 | String toJson() { | ||
| 57 | return JsonOutput.toJson(this) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | class JsonRpcNotification extends JsonRpcMessage { | ||
| 62 | String method | ||
| 63 | Object params | ||
| 64 | |||
| 65 | JsonRpcNotification(String method, Object params = null) { | ||
| 66 | this.method = method | ||
| 67 | this.params = params | ||
| 68 | } | ||
| 69 | |||
| 70 | String toJson() { | ||
| 71 | return JsonOutput.toJson(this) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | /** | ||
| 34 | * Enhanced MCP Servlet with proper SSE handling inspired by HttpServletSseServerTransportProvider | 76 | * Enhanced MCP Servlet with proper SSE handling inspired by HttpServletSseServerTransportProvider |
| 35 | * This implementation provides better SSE support and session management. | 77 | * This implementation provides better SSE support and session management. |
| 36 | */ | 78 | */ |
| ... | @@ -369,7 +411,7 @@ try { | ... | @@ -369,7 +411,7 @@ try { |
| 369 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec) | 411 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec) |
| 370 | 412 | ||
| 371 | // Send response via MCP transport to the specific session | 413 | // Send response via MCP transport to the specific session |
| 372 | def responseMessage = new McpSchema.JSONRPCMessage(result, rpcRequest.id) | 414 | def responseMessage = new JsonRpcResponse(result, rpcRequest.id) |
| 373 | session.sendMessage(responseMessage) | 415 | session.sendMessage(responseMessage) |
| 374 | 416 | ||
| 375 | response.setContentType("application/json") | 417 | response.setContentType("application/json") |
| ... | @@ -593,7 +635,7 @@ try { | ... | @@ -593,7 +635,7 @@ try { |
| 593 | /** | 635 | /** |
| 594 | * Broadcast message to all active sessions | 636 | * Broadcast message to all active sessions |
| 595 | */ | 637 | */ |
| 596 | void broadcastToAllSessions(McpSchema.JSONRPCMessage message) { | 638 | void broadcastToAllSessions(JsonRpcMessage message) { |
| 597 | sessionManager.broadcast(message) | 639 | sessionManager.broadcast(message) |
| 598 | } | 640 | } |
| 599 | 641 | ... | ... |
| ... | @@ -23,7 +23,7 @@ import javax.servlet.http.HttpServletResponse | ... | @@ -23,7 +23,7 @@ import javax.servlet.http.HttpServletResponse |
| 23 | class McpFilter implements Filter { | 23 | class McpFilter implements Filter { |
| 24 | protected final static Logger logger = LoggerFactory.getLogger(McpFilter.class) | 24 | protected final static Logger logger = LoggerFactory.getLogger(McpFilter.class) |
| 25 | 25 | ||
| 26 | private MoquiMcpServlet mcpServlet = new MoquiMcpServlet() | 26 | private EnhancedMcpServlet mcpServlet = new EnhancedMcpServlet() |
| 27 | 27 | ||
| 28 | @Override | 28 | @Override |
| 29 | void init(FilterConfig filterConfig) throws ServletException { | 29 | void init(FilterConfig filterConfig) throws ServletException { | ... | ... |
| ... | @@ -58,12 +58,11 @@ class McpSessionManager { | ... | @@ -58,12 +58,11 @@ class McpSessionManager { |
| 58 | logger.info("Registered MCP session ${session.sessionId} (total: ${sessions.size()})") | 58 | logger.info("Registered MCP session ${session.sessionId} (total: ${sessions.size()})") |
| 59 | 59 | ||
| 60 | // Send welcome message to new session | 60 | // Send welcome message to new session |
| 61 | def welcomeMessage = new McpSchema.JSONRPCMessage([ | 61 | def welcomeMessage = new JsonRpcNotification("welcome", [ |
| 62 | type: "welcome", | ||
| 63 | sessionId: session.sessionId, | 62 | sessionId: session.sessionId, |
| 64 | totalSessions: sessions.size(), | 63 | totalSessions: sessions.size(), |
| 65 | timestamp: System.currentTimeMillis() | 64 | timestamp: System.currentTimeMillis() |
| 66 | ], null) | 65 | ]) |
| 67 | session.sendMessage(welcomeMessage) | 66 | session.sendMessage(welcomeMessage) |
| 68 | } | 67 | } |
| 69 | 68 | ||
| ... | @@ -87,7 +86,7 @@ class McpSessionManager { | ... | @@ -87,7 +86,7 @@ class McpSessionManager { |
| 87 | /** | 86 | /** |
| 88 | * Broadcast message to all active sessions | 87 | * Broadcast message to all active sessions |
| 89 | */ | 88 | */ |
| 90 | void broadcast(McpSchema.JSONRPCMessage message) { | 89 | void broadcast(JsonRpcMessage message) { |
| 91 | if (isShuttingDown.get()) { | 90 | if (isShuttingDown.get()) { |
| 92 | logger.warn("Rejecting broadcast during shutdown") | 91 | logger.warn("Rejecting broadcast during shutdown") |
| 93 | return | 92 | return |
| ... | @@ -121,7 +120,7 @@ class McpSessionManager { | ... | @@ -121,7 +120,7 @@ class McpSessionManager { |
| 121 | /** | 120 | /** |
| 122 | * Send message to specific session | 121 | * Send message to specific session |
| 123 | */ | 122 | */ |
| 124 | boolean sendToSession(String sessionId, McpSchema.JSONRPCMessage message) { | 123 | boolean sendToSession(String sessionId, JsonRpcMessage message) { |
| 125 | def session = sessions.get(sessionId) | 124 | def session = sessions.get(sessionId) |
| 126 | if (!session) { | 125 | if (!session) { |
| 127 | return false | 126 | return false |
| ... | @@ -181,11 +180,10 @@ class McpSessionManager { | ... | @@ -181,11 +180,10 @@ class McpSessionManager { |
| 181 | logger.info("Initiating graceful MCP session manager shutdown") | 180 | logger.info("Initiating graceful MCP session manager shutdown") |
| 182 | 181 | ||
| 183 | // Send shutdown notification to all sessions | 182 | // Send shutdown notification to all sessions |
| 184 | def shutdownMessage = new McpSchema.JSONRPCMessage([ | 183 | def shutdownMessage = new JsonRpcNotification("server_shutdown", [ |
| 185 | type: "server_shutdown", | ||
| 186 | message: "Server is shutting down gracefully", | 184 | message: "Server is shutting down gracefully", |
| 187 | timestamp: System.currentTimeMillis() | 185 | timestamp: System.currentTimeMillis() |
| 188 | ], null) | 186 | ]) |
| 189 | broadcast(shutdownMessage) | 187 | broadcast(shutdownMessage) |
| 190 | 188 | ||
| 191 | // Give sessions time to receive shutdown message | 189 | // Give sessions time to receive shutdown message | ... | ... |
| 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 org.moqui.impl.context.ExecutionContextFactoryImpl | ||
| 18 | import org.moqui.context.ArtifactAuthorizationException | ||
| 19 | import org.moqui.context.ArtifactTarpitException | ||
| 20 | import org.moqui.impl.context.ExecutionContextImpl | ||
| 21 | import org.slf4j.Logger | ||
| 22 | import org.slf4j.LoggerFactory | ||
| 23 | |||
| 24 | import javax.servlet.ServletConfig | ||
| 25 | import javax.servlet.ServletException | ||
| 26 | import javax.servlet.http.HttpServlet | ||
| 27 | import javax.servlet.http.HttpServletRequest | ||
| 28 | import javax.servlet.http.HttpServletResponse | ||
| 29 | class MoquiMcpServlet extends HttpServlet { | ||
| 30 | protected final static Logger logger = LoggerFactory.getLogger(MoquiMcpServlet.class) | ||
| 31 | |||
| 32 | private JsonSlurper jsonSlurper = new JsonSlurper() | ||
| 33 | |||
| 34 | @Override | ||
| 35 | void init(ServletConfig config) throws ServletException { | ||
| 36 | super.init(config) | ||
| 37 | String webappName = config.getInitParameter("moqui-name") ?: | ||
| 38 | config.getServletContext().getInitParameter("moqui-name") | ||
| 39 | logger.info("MoquiMcpServlet initialized for webapp ${webappName}") | ||
| 40 | } | ||
| 41 | |||
| 42 | @Override | ||
| 43 | void service(HttpServletRequest request, HttpServletResponse response) | ||
| 44 | throws ServletException, IOException { | ||
| 45 | |||
| 46 | ExecutionContextFactoryImpl ecfi = | ||
| 47 | (ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory") | ||
| 48 | String webappName = getInitParameter("moqui-name") ?: | ||
| 49 | getServletContext().getInitParameter("moqui-name") | ||
| 50 | |||
| 51 | if (ecfi == null || webappName == null) { | ||
| 52 | response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, | ||
| 53 | "System is initializing, try again soon.") | ||
| 54 | return | ||
| 55 | } | ||
| 56 | |||
| 57 | // Handle CORS (following Moqui pattern) | ||
| 58 | if (handleCors(request, response, webappName, ecfi)) return | ||
| 59 | |||
| 60 | long startTime = System.currentTimeMillis() | ||
| 61 | |||
| 62 | if (logger.traceEnabled) { | ||
| 63 | logger.trace("Start MCP request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]") | ||
| 64 | } | ||
| 65 | |||
| 66 | ExecutionContextImpl activeEc = ecfi.activeContext.get() | ||
| 67 | if (activeEc != null) { | ||
| 68 | logger.warn("In MoquiMcpServlet.service there is already an ExecutionContext for user ${activeEc.user.username}") | ||
| 69 | activeEc.destroy() | ||
| 70 | } | ||
| 71 | |||
| 72 | ExecutionContextImpl ec = ecfi.getEci() | ||
| 73 | |||
| 74 | try { | ||
| 75 | // Initialize web facade for authentication but avoid screen system | ||
| 76 | ec.initWebFacade(webappName, request, response) | ||
| 77 | |||
| 78 | logger.info("MCP Request authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}") | ||
| 79 | |||
| 80 | // If no user authenticated, try to authenticate as admin for MCP requests | ||
| 81 | if (!ec.user?.userId) { | ||
| 82 | logger.info("No user authenticated, attempting admin login for MCP") | ||
| 83 | try { | ||
| 84 | ec.user.loginUser("admin", "admin") | ||
| 85 | logger.info("MCP Admin login successful, user: ${ec.user?.username}") | ||
| 86 | } catch (Exception e) { | ||
| 87 | logger.warn("MCP Admin login failed: ${e.message}") | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | // Handle MCP JSON-RPC protocol | ||
| 92 | handleMcpRequest(request, response, ec) | ||
| 93 | |||
| 94 | } catch (ArtifactAuthorizationException e) { | ||
| 95 | logger.warn("MCP Access Forbidden (no authz): " + e.message) | ||
| 96 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 97 | response.setStatus(HttpServletResponse.SC_FORBIDDEN) | ||
| 98 | response.setContentType("application/json") | ||
| 99 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 100 | jsonrpc: "2.0", | ||
| 101 | error: [code: -32001, message: "Access Forbidden: " + e.message], | ||
| 102 | id: null | ||
| 103 | ])) | ||
| 104 | } catch (ArtifactTarpitException e) { | ||
| 105 | logger.warn("MCP Too Many Requests (tarpit): " + e.message) | ||
| 106 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 107 | response.setStatus(429) | ||
| 108 | if (e.getRetryAfterSeconds()) { | ||
| 109 | response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) | ||
| 110 | } | ||
| 111 | response.setContentType("application/json") | ||
| 112 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 113 | jsonrpc: "2.0", | ||
| 114 | error: [code: -32002, message: "Too Many Requests: " + e.message], | ||
| 115 | id: null | ||
| 116 | ])) | ||
| 117 | } catch (Throwable t) { | ||
| 118 | logger.error("Error in MCP request", t) | ||
| 119 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 120 | response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) | ||
| 121 | response.setContentType("application/json") | ||
| 122 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 123 | jsonrpc: "2.0", | ||
| 124 | error: [code: -32603, message: "Internal error: " + t.message], | ||
| 125 | id: null | ||
| 126 | ])) | ||
| 127 | } finally { | ||
| 128 | ec.destroy() | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | private void handleMcpRequest(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec) | ||
| 133 | throws IOException { | ||
| 134 | |||
| 135 | String method = request.getMethod() | ||
| 136 | String acceptHeader = request.getHeader("Accept") | ||
| 137 | String contentType = request.getContentType() | ||
| 138 | String userAgent = request.getHeader("User-Agent") | ||
| 139 | |||
| 140 | logger.info("MCP Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}, User-Agent: ${userAgent}") | ||
| 141 | |||
| 142 | // Handle SSE (Server-Sent Events) for streaming | ||
| 143 | if ("GET".equals(method) && acceptHeader != null && acceptHeader.contains("text/event-stream")) { | ||
| 144 | logger.info("Processing SSE request - GET with text/event-stream Accept header") | ||
| 145 | handleSseRequest(request, response, ec) | ||
| 146 | return | ||
| 147 | } | ||
| 148 | |||
| 149 | // Handle POST requests for JSON-RPC | ||
| 150 | if (!"POST".equals(method)) { | ||
| 151 | logger.warn("Rejecting non-POST request: ${method} - Only POST for JSON-RPC or GET with Accept: text/event-stream for SSE allowed") | ||
| 152 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 153 | response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) | ||
| 154 | response.setContentType("application/json") | ||
| 155 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 156 | jsonrpc: "2.0", | ||
| 157 | error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET with Accept: text/event-stream for SSE."], | ||
| 158 | id: null | ||
| 159 | ])) | ||
| 160 | return | ||
| 161 | } | ||
| 162 | |||
| 163 | // Read and parse JSON-RPC request following official MCP servlet pattern | ||
| 164 | logger.info("Processing JSON-RPC POST request") | ||
| 165 | |||
| 166 | String requestBody | ||
| 167 | try { | ||
| 168 | // Use BufferedReader pattern from official MCP servlet | ||
| 169 | BufferedReader reader = request.reader | ||
| 170 | StringBuilder body = new StringBuilder() | ||
| 171 | String line | ||
| 172 | while ((line = reader.readLine()) != null) { | ||
| 173 | body.append(line) | ||
| 174 | } | ||
| 175 | |||
| 176 | requestBody = body.toString() | ||
| 177 | logger.info("JSON-RPC request body (${requestBody.length()} chars): ${requestBody}") | ||
| 178 | |||
| 179 | } catch (IOException e) { | ||
| 180 | logger.error("Failed to read request body: ${e.message}") | ||
| 181 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 182 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | ||
| 183 | response.setContentType("application/json") | ||
| 184 | response.setCharacterEncoding("UTF-8") | ||
| 185 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 186 | jsonrpc: "2.0", | ||
| 187 | error: [code: -32700, message: "Failed to read request body: " + e.message], | ||
| 188 | id: null | ||
| 189 | ])) | ||
| 190 | return | ||
| 191 | } | ||
| 192 | |||
| 193 | if (!requestBody) { | ||
| 194 | logger.warn("Empty request body in JSON-RPC POST request") | ||
| 195 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 196 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | ||
| 197 | response.setContentType("application/json") | ||
| 198 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 199 | jsonrpc: "2.0", | ||
| 200 | error: [code: -32602, message: "Empty request body"], | ||
| 201 | id: null | ||
| 202 | ])) | ||
| 203 | return | ||
| 204 | } | ||
| 205 | |||
| 206 | def rpcRequest | ||
| 207 | try { | ||
| 208 | rpcRequest = jsonSlurper.parseText(requestBody) | ||
| 209 | logger.info("Parsed JSON-RPC request: method=${rpcRequest.method}, id=${rpcRequest.id}") | ||
| 210 | } catch (Exception e) { | ||
| 211 | logger.error("Failed to parse JSON-RPC request: ${e.message}") | ||
| 212 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 213 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | ||
| 214 | response.setContentType("application/json") | ||
| 215 | response.setCharacterEncoding("UTF-8") | ||
| 216 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 217 | jsonrpc: "2.0", | ||
| 218 | error: [code: -32700, message: "Invalid JSON: " + e.message], | ||
| 219 | id: null | ||
| 220 | ])) | ||
| 221 | return | ||
| 222 | } | ||
| 223 | |||
| 224 | // Validate JSON-RPC 2.0 basic structure | ||
| 225 | if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) { | ||
| 226 | logger.warn("Invalid JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}") | ||
| 227 | // Handle error directly without sendError to avoid Moqui error screen interference | ||
| 228 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | ||
| 229 | response.setContentType("application/json") | ||
| 230 | response.setCharacterEncoding("UTF-8") | ||
| 231 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 232 | jsonrpc: "2.0", | ||
| 233 | error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"], | ||
| 234 | id: null | ||
| 235 | ])) | ||
| 236 | return | ||
| 237 | } | ||
| 238 | |||
| 239 | // Process MCP method | ||
| 240 | logger.info("Calling processMcpMethod with method: ${rpcRequest.method}, params: ${rpcRequest.params}") | ||
| 241 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec) | ||
| 242 | logger.info("processMcpMethod returned result: ${result}") | ||
| 243 | |||
| 244 | // Build JSON-RPC response | ||
| 245 | def rpcResponse = [ | ||
| 246 | jsonrpc: "2.0", | ||
| 247 | id: rpcRequest.id, | ||
| 248 | result: result | ||
| 249 | ] | ||
| 250 | logger.info("Sending JSON-RPC response: ${rpcResponse}") | ||
| 251 | |||
| 252 | // Send response following official MCP servlet pattern | ||
| 253 | response.setContentType("application/json") | ||
| 254 | response.setCharacterEncoding("UTF-8") | ||
| 255 | response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse)) | ||
| 256 | } | ||
| 257 | |||
| 258 | private void handleSseRequest(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec) | ||
| 259 | throws IOException { | ||
| 260 | |||
| 261 | logger.info("Handling SSE request from ${request.remoteAddr}") | ||
| 262 | |||
| 263 | // Set SSE headers | ||
| 264 | response.setContentType("text/event-stream") | ||
| 265 | response.setCharacterEncoding("UTF-8") | ||
| 266 | response.setHeader("Cache-Control", "no-cache") | ||
| 267 | response.setHeader("Connection", "keep-alive") | ||
| 268 | |||
| 269 | // Send initial connection event | ||
| 270 | response.writer.write("event: connect\n") | ||
| 271 | response.writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n") | ||
| 272 | response.writer.flush() | ||
| 273 | |||
| 274 | // Keep connection alive with periodic pings | ||
| 275 | long startTime = System.currentTimeMillis() | ||
| 276 | int pingCount = 0 | ||
| 277 | |||
| 278 | try { | ||
| 279 | while (!response.isCommitted() && pingCount < 10) { // Limit to 10 pings for testing | ||
| 280 | Thread.sleep(5000) // Wait 5 seconds | ||
| 281 | |||
| 282 | if (!response.isCommitted()) { | ||
| 283 | response.writer.write("event: ping\n") | ||
| 284 | response.writer.write("data: {\"type\":\"ping\",\"count\":${pingCount},\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n") | ||
| 285 | response.writer.flush() | ||
| 286 | pingCount++ | ||
| 287 | } | ||
| 288 | } | ||
| 289 | } catch (Exception e) { | ||
| 290 | logger.warn("SSE connection interrupted: ${e.message}") | ||
| 291 | } finally { | ||
| 292 | // Send close event | ||
| 293 | if (!response.isCommitted()) { | ||
| 294 | response.writer.write("event: close\n") | ||
| 295 | response.writer.write("data: {\"type\":\"disconnected\",\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n") | ||
| 296 | response.writer.flush() | ||
| 297 | } | ||
| 298 | } | ||
| 299 | } | ||
| 300 | |||
| 301 | private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec) { | ||
| 302 | logger.info("METHOD: ${method} with params: ${params}") | ||
| 303 | switch (method) { | ||
| 304 | case "initialize": | ||
| 305 | return callMcpService("mcp#Initialize", params, ec) | ||
| 306 | case "ping": | ||
| 307 | return callMcpService("mcp#Ping", params, ec) | ||
| 308 | case "tools/list": | ||
| 309 | return callMcpService("mcp#ToolsList", params, ec) | ||
| 310 | case "tools/call": | ||
| 311 | return callMcpService("mcp#ToolsCall", params, ec) | ||
| 312 | case "resources/list": | ||
| 313 | return callMcpService("mcp#ResourcesList", params, ec) | ||
| 314 | case "resources/read": | ||
| 315 | return callMcpService("mcp#ResourcesRead", params, ec) | ||
| 316 | default: | ||
| 317 | throw new IllegalArgumentException("Unknown MCP method: ${method}") | ||
| 318 | } | ||
| 319 | } | ||
| 320 | |||
| 321 | private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) { | ||
| 322 | logger.info("Calling MCP service: ${serviceName} with params: ${params}") | ||
| 323 | |||
| 324 | try { | ||
| 325 | def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}") | ||
| 326 | .parameters(params ?: [:]) | ||
| 327 | .call() | ||
| 328 | |||
| 329 | logger.info("MCP service ${serviceName} result: ${result}") | ||
| 330 | return result.result | ||
| 331 | } catch (Exception e) { | ||
| 332 | logger.error("Error calling MCP service ${serviceName}", e) | ||
| 333 | throw e | ||
| 334 | } | ||
| 335 | } | ||
| 336 | |||
| 337 | private Map<String, Object> initializeMcp(Map params, ExecutionContextImpl ec) { | ||
| 338 | logger.info("MCP Initialize called with params: ${params}") | ||
| 339 | |||
| 340 | // Discover available tools and resources | ||
| 341 | def toolsResult = listTools([:], ec) | ||
| 342 | def resourcesResult = listResources([:], ec) | ||
| 343 | |||
| 344 | def capabilities = [ | ||
| 345 | tools: [:], | ||
| 346 | resources: [:], | ||
| 347 | logging: [:] | ||
| 348 | ] | ||
| 349 | |||
| 350 | // Only include tools if we found any | ||
| 351 | if (toolsResult?.tools) { | ||
| 352 | capabilities.tools = [listChanged: true] | ||
| 353 | } | ||
| 354 | |||
| 355 | // Only include resources if we found any | ||
| 356 | if (resourcesResult?.resources) { | ||
| 357 | capabilities.resources = [subscribe: true, listChanged: true] | ||
| 358 | } | ||
| 359 | |||
| 360 | def initResult = [ | ||
| 361 | protocolVersion: "2025-06-18", | ||
| 362 | capabilities: capabilities, | ||
| 363 | serverInfo: [ | ||
| 364 | name: "Moqui MCP Server", | ||
| 365 | version: "2.0.0" | ||
| 366 | ] | ||
| 367 | ] | ||
| 368 | |||
| 369 | logger.info("MCP Initialize returning: ${initResult}") | ||
| 370 | return initResult | ||
| 371 | } | ||
| 372 | |||
| 373 | private Map<String, Object> pingMcp(Map params, ExecutionContextImpl ec) { | ||
| 374 | logger.info("MCP Ping called with params: ${params}") | ||
| 375 | |||
| 376 | return [ | ||
| 377 | result: "pong" | ||
| 378 | ] | ||
| 379 | } | ||
| 380 | |||
| 381 | private Map<String, Object> listTools(Map params, ExecutionContextImpl ec) { | ||
| 382 | // List available Moqui services as tools | ||
| 383 | def tools = [] | ||
| 384 | |||
| 385 | // Entity services | ||
| 386 | tools << [ | ||
| 387 | name: "EntityFind", | ||
| 388 | description: "Find entities in Moqui", | ||
| 389 | inputSchema: [ | ||
| 390 | type: "object", | ||
| 391 | properties: [ | ||
| 392 | entity: [type: "string", description: "Entity name"], | ||
| 393 | fields: [type: "array", description: "Fields to select"], | ||
| 394 | constraint: [type: "string", description: "Constraint expression"], | ||
| 395 | limit: [type: "number", description: "Maximum results"] | ||
| 396 | ] | ||
| 397 | ] | ||
| 398 | ] | ||
| 399 | |||
| 400 | tools << [ | ||
| 401 | name: "EntityCreate", | ||
| 402 | description: "Create entity records", | ||
| 403 | inputSchema: [ | ||
| 404 | type: "object", | ||
| 405 | properties: [ | ||
| 406 | entity: [type: "string", description: "Entity name"], | ||
| 407 | fields: [type: "object", description: "Field values"] | ||
| 408 | ] | ||
| 409 | ] | ||
| 410 | ] | ||
| 411 | |||
| 412 | tools << [ | ||
| 413 | name: "EntityUpdate", | ||
| 414 | description: "Update entity records", | ||
| 415 | inputSchema: [ | ||
| 416 | type: "object", | ||
| 417 | properties: [ | ||
| 418 | entity: [type: "string", description: "Entity name"], | ||
| 419 | fields: [type: "object", description: "Field values"], | ||
| 420 | constraint: [type: "string", description: "Constraint expression"] | ||
| 421 | ] | ||
| 422 | ] | ||
| 423 | ] | ||
| 424 | |||
| 425 | tools << [ | ||
| 426 | name: "EntityDelete", | ||
| 427 | description: "Delete entity records", | ||
| 428 | inputSchema: [ | ||
| 429 | type: "object", | ||
| 430 | properties: [ | ||
| 431 | entity: [type: "string", description: "Entity name"], | ||
| 432 | constraint: [type: "string", description: "Constraint expression"] | ||
| 433 | ] | ||
| 434 | ] | ||
| 435 | ] | ||
| 436 | |||
| 437 | // Service execution tools | ||
| 438 | tools << [ | ||
| 439 | name: "ServiceCall", | ||
| 440 | description: "Execute Moqui services", | ||
| 441 | inputSchema: [ | ||
| 442 | type: "object", | ||
| 443 | properties: [ | ||
| 444 | service: [type: "string", description: "Service name (verb:noun)"], | ||
| 445 | parameters: [type: "object", description: "Service parameters"] | ||
| 446 | ] | ||
| 447 | ] | ||
| 448 | ] | ||
| 449 | |||
| 450 | // User management tools | ||
| 451 | tools << [ | ||
| 452 | name: "UserFind", | ||
| 453 | description: "Find users in the system", | ||
| 454 | inputSchema: [ | ||
| 455 | type: "object", | ||
| 456 | properties: [ | ||
| 457 | username: [type: "string", description: "Username filter"], | ||
| 458 | email: [type: "string", description: "Email filter"], | ||
| 459 | enabled: [type: "boolean", description: "Filter by enabled status"] | ||
| 460 | ] | ||
| 461 | ] | ||
| 462 | ] | ||
| 463 | |||
| 464 | // Party management tools | ||
| 465 | tools << [ | ||
| 466 | name: "PartyFind", | ||
| 467 | description: "Find parties (organizations, persons)", | ||
| 468 | inputSchema: [ | ||
| 469 | type: "object", | ||
| 470 | properties: [ | ||
| 471 | partyType: [type: "string", description: "Party type (PERSON, ORGANIZATION)"], | ||
| 472 | partyName: [type: "string", description: "Party name filter"], | ||
| 473 | status: [type: "string", description: "Status filter"] | ||
| 474 | ] | ||
| 475 | ] | ||
| 476 | ] | ||
| 477 | |||
| 478 | // Order management tools | ||
| 479 | tools << [ | ||
| 480 | name: "OrderFind", | ||
| 481 | description: "Find sales orders", | ||
| 482 | inputSchema: [ | ||
| 483 | type: "object", | ||
| 484 | properties: [ | ||
| 485 | orderId: [type: "string", description: "Order ID"], | ||
| 486 | customerId: [type: "string", description: "Customer party ID"], | ||
| 487 | status: [type: "string", description: "Order status"], | ||
| 488 | fromDate: [type: "string", description: "From date (YYYY-MM-DD)"], | ||
| 489 | thruDate: [type: "string", description: "Thru date (YYYY-MM-DD)"] | ||
| 490 | ] | ||
| 491 | ] | ||
| 492 | ] | ||
| 493 | |||
| 494 | // Product management tools | ||
| 495 | tools << [ | ||
| 496 | name: "ProductFind", | ||
| 497 | description: "Find products", | ||
| 498 | inputSchema: [ | ||
| 499 | type: "object", | ||
| 500 | properties: [ | ||
| 501 | productId: [type: "string", description: "Product ID"], | ||
| 502 | productName: [type: "string", description: "Product name filter"], | ||
| 503 | productType: [type: "string", description: "Product type"], | ||
| 504 | category: [type: "string", description: "Product category"] | ||
| 505 | ] | ||
| 506 | ] | ||
| 507 | ] | ||
| 508 | |||
| 509 | // Inventory tools | ||
| 510 | tools << [ | ||
| 511 | name: "InventoryCheck", | ||
| 512 | description: "Check product inventory levels", | ||
| 513 | inputSchema: [ | ||
| 514 | type: "object", | ||
| 515 | properties: [ | ||
| 516 | productId: [type: "string", description: "Product ID"], | ||
| 517 | facilityId: [type: "string", description: "Facility ID"], | ||
| 518 | locationId: [type: "string", description: "Location ID"] | ||
| 519 | ] | ||
| 520 | ] | ||
| 521 | ] | ||
| 522 | |||
| 523 | // System status tools | ||
| 524 | tools << [ | ||
| 525 | name: "SystemStatus", | ||
| 526 | description: "Get system status and statistics", | ||
| 527 | inputSchema: [ | ||
| 528 | type: "object", | ||
| 529 | properties: [ | ||
| 530 | includeMetrics: [type: "boolean", description: "Include performance metrics"], | ||
| 531 | includeCache: [type: "boolean", description: "Include cache statistics"] | ||
| 532 | ] | ||
| 533 | ] | ||
| 534 | ] | ||
| 535 | |||
| 536 | return [tools: tools] | ||
| 537 | } | ||
| 538 | |||
| 539 | private Map<String, Object> callTool(Map params, ExecutionContextImpl ec) { | ||
| 540 | String toolName = params.name as String | ||
| 541 | Map arguments = params.arguments as Map ?: [:] | ||
| 542 | |||
| 543 | logger.info("Calling tool via service: ${toolName} with arguments: ${arguments}") | ||
| 544 | |||
| 545 | try { | ||
| 546 | // Use the existing McpServices.mcp#ToolsCall service | ||
| 547 | def result = ec.service.sync().name("org.moqui.mcp.McpServices.mcp#ToolsCall") | ||
| 548 | .parameters([name: toolName, arguments: arguments]) | ||
| 549 | .call() | ||
| 550 | |||
| 551 | logger.info("Tool call result: ${result}") | ||
| 552 | return result.result | ||
| 553 | } catch (Exception e) { | ||
| 554 | logger.error("Error calling tool ${toolName} via service", e) | ||
| 555 | return [ | ||
| 556 | content: [[type: "text", text: "Error: " + e.message]], | ||
| 557 | isError: true | ||
| 558 | ] | ||
| 559 | } | ||
| 560 | } | ||
| 561 | |||
| 562 | private Map<String, Object> callEntityFind(Map arguments, ExecutionContextImpl ec) { | ||
| 563 | String entity = arguments.entity as String | ||
| 564 | List<String> fields = arguments.fields as List<String> | ||
| 565 | String constraint = arguments.constraint as String | ||
| 566 | Integer limit = arguments.limit as Integer | ||
| 567 | |||
| 568 | def finder = ec.entity.find(entity).selectFields(fields ?: ["*"]).limit(limit ?: 100) | ||
| 569 | if (constraint) { | ||
| 570 | finder.condition(constraint) | ||
| 571 | } | ||
| 572 | def result = finder.list() | ||
| 573 | |||
| 574 | return [ | ||
| 575 | content: [[type: "text", text: "Found ${result.size()} records: ${result}"]], | ||
| 576 | isError: false | ||
| 577 | ] | ||
| 578 | } | ||
| 579 | |||
| 580 | private Map<String, Object> callEntityCreate(Map arguments, ExecutionContextImpl ec) { | ||
| 581 | String entity = arguments.entity as String | ||
| 582 | Map fields = arguments.fields as Map | ||
| 583 | |||
| 584 | def result = ec.entity.create(entity).setAll(fields).create() | ||
| 585 | |||
| 586 | return [ | ||
| 587 | content: [[type: "text", text: "Created record: ${result}"]], | ||
| 588 | isError: false | ||
| 589 | ] | ||
| 590 | } | ||
| 591 | |||
| 592 | private Map<String, Object> callEntityUpdate(Map arguments, ExecutionContextImpl ec) { | ||
| 593 | String entity = arguments.entity as String | ||
| 594 | Map fields = arguments.fields as Map | ||
| 595 | String constraint = arguments.constraint as String | ||
| 596 | |||
| 597 | def updater = ec.entity.update(entity).setAll(fields) | ||
| 598 | if (constraint) { | ||
| 599 | updater.condition(constraint) | ||
| 600 | } | ||
| 601 | int updated = updater.update() | ||
| 602 | |||
| 603 | return [ | ||
| 604 | content: [[type: "text", text: "Updated ${updated} records"]], | ||
| 605 | isError: false | ||
| 606 | ] | ||
| 607 | } | ||
| 608 | |||
| 609 | private Map<String, Object> callEntityDelete(Map arguments, ExecutionContextImpl ec) { | ||
| 610 | String entity = arguments.entity as String | ||
| 611 | String constraint = arguments.constraint as String | ||
| 612 | |||
| 613 | def deleter = ec.entity.delete(entity) | ||
| 614 | if (constraint) { | ||
| 615 | deleter.condition(constraint) | ||
| 616 | } | ||
| 617 | int deleted = deleter.delete() | ||
| 618 | |||
| 619 | return [ | ||
| 620 | content: [[type: "text", text: "Deleted ${deleted} records"]], | ||
| 621 | isError: false | ||
| 622 | ] | ||
| 623 | } | ||
| 624 | |||
| 625 | private Map<String, Object> callService(Map arguments, ExecutionContextImpl ec) { | ||
| 626 | String serviceName = arguments.service as String | ||
| 627 | Map parameters = arguments.parameters as Map ?: [:] | ||
| 628 | |||
| 629 | try { | ||
| 630 | def result = ec.service.sync().name(serviceName).parameters(parameters).call() | ||
| 631 | return [ | ||
| 632 | content: [[type: "text", text: "Service ${serviceName} executed successfully. Result: ${result}"]], | ||
| 633 | isError: false | ||
| 634 | ] | ||
| 635 | } catch (Exception e) { | ||
| 636 | return [ | ||
| 637 | content: [[type: "text", text: "Error executing service ${serviceName}: ${e.message}"]], | ||
| 638 | isError: true | ||
| 639 | ] | ||
| 640 | } | ||
| 641 | } | ||
| 642 | |||
| 643 | private Map<String, Object> callUserFind(Map arguments, ExecutionContextImpl ec) { | ||
| 644 | String username = arguments.username as String | ||
| 645 | String email = arguments.email as String | ||
| 646 | Boolean enabled = arguments.enabled as Boolean | ||
| 647 | |||
| 648 | def condition = new StringBuilder("1=1") | ||
| 649 | def parameters = [:] | ||
| 650 | |||
| 651 | if (username) { | ||
| 652 | condition.append(" AND username = :username") | ||
| 653 | parameters.username = username | ||
| 654 | } | ||
| 655 | if (email) { | ||
| 656 | condition.append(" AND email_address = :email") | ||
| 657 | parameters.email = email | ||
| 658 | } | ||
| 659 | if (enabled != null) { | ||
| 660 | condition.append(" AND enabled = :enabled") | ||
| 661 | parameters.enabled = enabled ? "Y" : "N" | ||
| 662 | } | ||
| 663 | |||
| 664 | def result = ec.entity.find("moqui.security.UserAccount") | ||
| 665 | .condition(condition.toString(), parameters) | ||
| 666 | .limit(50) | ||
| 667 | .list() | ||
| 668 | |||
| 669 | return [ | ||
| 670 | content: [[type: "text", text: "Found ${result.size()} users: ${result.collect { [username: it.username, email: it.emailAddress, enabled: it.enabled] }}"]], | ||
| 671 | isError: false | ||
| 672 | ] | ||
| 673 | } | ||
| 674 | |||
| 675 | private Map<String, Object> callPartyFind(Map arguments, ExecutionContextImpl ec) { | ||
| 676 | String partyType = arguments.partyType as String | ||
| 677 | String partyName = arguments.partyName as String | ||
| 678 | String status = arguments.status as String | ||
| 679 | |||
| 680 | def condition = new StringBuilder("1=1") | ||
| 681 | def parameters = [:] | ||
| 682 | |||
| 683 | if (partyType) { | ||
| 684 | condition.append(" AND party_type_id = :partyType") | ||
| 685 | parameters.partyType = partyType | ||
| 686 | } | ||
| 687 | if (partyName) { | ||
| 688 | condition.append(" AND (party_name ILIKE :partyName OR party_name ILIKE :partyName)") | ||
| 689 | parameters.partyName = "%${partyName}%" | ||
| 690 | } | ||
| 691 | if (status) { | ||
| 692 | condition.append(" AND status_id = :status") | ||
| 693 | parameters.status = status | ||
| 694 | } | ||
| 695 | |||
| 696 | def result = ec.entity.find("mantle.party.PartyAndName") | ||
| 697 | .condition(condition.toString(), parameters) | ||
| 698 | .limit(50) | ||
| 699 | .list() | ||
| 700 | |||
| 701 | return [ | ||
| 702 | content: [[type: "text", text: "Found ${result.size()} parties: ${result.collect { [partyId: it.partyId, type: it.partyTypeId, name: it.partyName, status: it.statusId] }}"]], | ||
| 703 | isError: false | ||
| 704 | ] | ||
| 705 | } | ||
| 706 | |||
| 707 | private Map<String, Object> callOrderFind(Map arguments, ExecutionContextImpl ec) { | ||
| 708 | String orderId = arguments.orderId as String | ||
| 709 | String customerId = arguments.customerId as String | ||
| 710 | String status = arguments.status as String | ||
| 711 | String fromDate = arguments.fromDate as String | ||
| 712 | String thruDate = arguments.thruDate as String | ||
| 713 | |||
| 714 | def condition = new StringBuilder("1=1") | ||
| 715 | def parameters = [:] | ||
| 716 | |||
| 717 | if (orderId) { | ||
| 718 | condition.append(" AND order_id = :orderId") | ||
| 719 | parameters.orderId = orderId | ||
| 720 | } | ||
| 721 | if (customerId) { | ||
| 722 | condition.append(" AND customer_party_id = :customerId") | ||
| 723 | parameters.customerId = customerId | ||
| 724 | } | ||
| 725 | if (status) { | ||
| 726 | condition.append(" AND status_id = :status") | ||
| 727 | parameters.status = status | ||
| 728 | } | ||
| 729 | if (fromDate) { | ||
| 730 | condition.append(" AND order_date >= :fromDate") | ||
| 731 | parameters.fromDate = fromDate | ||
| 732 | } | ||
| 733 | if (thruDate) { | ||
| 734 | condition.append(" AND order_date <= :thruDate") | ||
| 735 | parameters.thruDate = thruDate | ||
| 736 | } | ||
| 737 | |||
| 738 | def result = ec.entity.find("mantle.order.OrderHeader") | ||
| 739 | .condition(condition.toString(), parameters) | ||
| 740 | .limit(50) | ||
| 741 | .list() | ||
| 742 | |||
| 743 | return [ | ||
| 744 | content: [[type: "text", text: "Found ${result.size()} orders: ${result.collect { [orderId: it.orderId, customer: it.customerPartyId, status: it.statusId, date: it.orderDate, total: it.grandTotal] }}"]], | ||
| 745 | isError: false | ||
| 746 | ] | ||
| 747 | } | ||
| 748 | |||
| 749 | private Map<String, Object> callProductFind(Map arguments, ExecutionContextImpl ec) { | ||
| 750 | String productId = arguments.productId as String | ||
| 751 | String productName = arguments.productName as String | ||
| 752 | String productType = arguments.productType as String | ||
| 753 | String category = arguments.category as String | ||
| 754 | |||
| 755 | def condition = new StringBuilder("1=1") | ||
| 756 | def parameters = [:] | ||
| 757 | |||
| 758 | if (productId) { | ||
| 759 | condition.append(" AND product_id = :productId") | ||
| 760 | parameters.productId = productId | ||
| 761 | } | ||
| 762 | if (productName) { | ||
| 763 | condition.append(" AND (product_name ILIKE :productName OR internal_name ILIKE :productName)") | ||
| 764 | parameters.productName = "%${productName}%" | ||
| 765 | } | ||
| 766 | if (productType) { | ||
| 767 | condition.append(" AND product_type_id = :productType") | ||
| 768 | parameters.productType = productType | ||
| 769 | } | ||
| 770 | if (category) { | ||
| 771 | condition.append(" AND primary_product_category_id = :category") | ||
| 772 | parameters.category = category | ||
| 773 | } | ||
| 774 | |||
| 775 | def result = ec.entity.find("mantle.product.Product") | ||
| 776 | .condition(condition.toString(), parameters) | ||
| 777 | .limit(50) | ||
| 778 | .list() | ||
| 779 | |||
| 780 | return [ | ||
| 781 | content: [[type: "text", text: "Found ${result.size()} products: ${result.collect { [productId: it.productId, name: it.productName, type: it.productTypeId, category: it.primaryProductCategoryId] }}"]], | ||
| 782 | isError: false | ||
| 783 | ] | ||
| 784 | } | ||
| 785 | |||
| 786 | private Map<String, Object> callInventoryCheck(Map arguments, ExecutionContextImpl ec) { | ||
| 787 | String productId = arguments.productId as String | ||
| 788 | String facilityId = arguments.facilityId as String | ||
| 789 | String locationId = arguments.locationId as String | ||
| 790 | |||
| 791 | if (!productId) { | ||
| 792 | return [ | ||
| 793 | content: [[type: "text", text: "Error: productId is required"]], | ||
| 794 | isError: true | ||
| 795 | ] | ||
| 796 | } | ||
| 797 | |||
| 798 | def condition = new StringBuilder("product_id = :productId") | ||
| 799 | def parameters = [productId: productId] | ||
| 800 | |||
| 801 | if (facilityId) { | ||
| 802 | condition.append(" AND facility_id = :facilityId") | ||
| 803 | parameters.facilityId = facilityId | ||
| 804 | } | ||
| 805 | if (locationId) { | ||
| 806 | condition.append(" AND location_id = :locationId") | ||
| 807 | parameters.locationId = locationId | ||
| 808 | } | ||
| 809 | |||
| 810 | def result = ec.entity.find("mantle.product.inventory.InventoryItem") | ||
| 811 | .condition(condition.toString(), parameters) | ||
| 812 | .list() | ||
| 813 | |||
| 814 | def totalAvailable = result.sum { it.availableToPromiseTotal ?: 0 } | ||
| 815 | def totalOnHand = result.sum { it.quantityOnHandTotal ?: 0 } | ||
| 816 | |||
| 817 | return [ | ||
| 818 | content: [[type: "text", text: "Inventory for ${productId}: Available: ${totalAvailable}, On Hand: ${totalOnHand}, Facilities: ${result.collect { [facility: it.facilityId, location: it.locationId, available: it.availableToPromiseTotal, onHand: it.quantityOnHandTotal] }}"]], | ||
| 819 | isError: false | ||
| 820 | ] | ||
| 821 | } | ||
| 822 | |||
| 823 | private Map<String, Object> callSystemStatus(Map arguments, ExecutionContextImpl ec) { | ||
| 824 | Boolean includeMetrics = arguments.includeMetrics as Boolean ?: false | ||
| 825 | Boolean includeCache = arguments.includeCache as Boolean ?: false | ||
| 826 | |||
| 827 | def status = [ | ||
| 828 | serverTime: new Date(), | ||
| 829 | frameworkVersion: "3.1.0-rc2", | ||
| 830 | userCount: ec.entity.find("moqui.security.UserAccount").count(), | ||
| 831 | partyCount: ec.entity.find("mantle.party.Party").count(), | ||
| 832 | productCount: ec.entity.find("mantle.product.Product").count(), | ||
| 833 | orderCount: ec.entity.find("mantle.order.OrderHeader").count() | ||
| 834 | ] | ||
| 835 | |||
| 836 | if (includeMetrics) { | ||
| 837 | status.memory = [ | ||
| 838 | total: Runtime.getRuntime().totalMemory(), | ||
| 839 | free: Runtime.getRuntime().freeMemory(), | ||
| 840 | max: Runtime.getRuntime().maxMemory() | ||
| 841 | ] | ||
| 842 | } | ||
| 843 | |||
| 844 | if (includeCache) { | ||
| 845 | def cacheFacade = ec.getCache() | ||
| 846 | status.cache = [ | ||
| 847 | cacheNames: cacheFacade.getCacheNames(), | ||
| 848 | // Note: More detailed cache stats would require cache-specific API calls | ||
| 849 | ] | ||
| 850 | } | ||
| 851 | |||
| 852 | return [ | ||
| 853 | content: [[type: "text", text: "System Status: ${status}"]], | ||
| 854 | isError: false | ||
| 855 | ] | ||
| 856 | } | ||
| 857 | |||
| 858 | private Map<String, Object> listResources(Map params, ExecutionContextImpl ec) { | ||
| 859 | // List available entities as resources | ||
| 860 | def resources = [] | ||
| 861 | |||
| 862 | // Get all entity names | ||
| 863 | def entityNames = ec.entity.getEntityNames() | ||
| 864 | for (String entityName : entityNames) { | ||
| 865 | resources << [ | ||
| 866 | uri: "entity://${entityName}", | ||
| 867 | name: entityName, | ||
| 868 | description: "Moqui Entity: ${entityName}", | ||
| 869 | mimeType: "application/json" | ||
| 870 | ] | ||
| 871 | } | ||
| 872 | |||
| 873 | return [resources: resources] | ||
| 874 | } | ||
| 875 | |||
| 876 | private Map<String, Object> readResource(Map params, ExecutionContextImpl ec) { | ||
| 877 | String uri = params.uri as String | ||
| 878 | |||
| 879 | if (uri.startsWith("entity://")) { | ||
| 880 | String entityName = uri.substring(9) // Remove "entity://" prefix | ||
| 881 | |||
| 882 | try { | ||
| 883 | // Get entity definition | ||
| 884 | def entityDef = ec.entity.getEntityDefinition(entityName) | ||
| 885 | if (!entityDef) { | ||
| 886 | throw new IllegalArgumentException("Entity not found: ${entityName}") | ||
| 887 | } | ||
| 888 | |||
| 889 | // Get basic entity info | ||
| 890 | def entityInfo = [ | ||
| 891 | name: entityName, | ||
| 892 | tableName: entityDef.tableName, | ||
| 893 | fields: entityDef.allFieldInfo.collect { [name: it.name, type: it.type] } | ||
| 894 | ] | ||
| 895 | |||
| 896 | return [ | ||
| 897 | contents: [[ | ||
| 898 | uri: uri, | ||
| 899 | mimeType: "application/json", | ||
| 900 | text: groovy.json.JsonOutput.toJson(entityInfo) | ||
| 901 | ]] | ||
| 902 | ] | ||
| 903 | } catch (Exception e) { | ||
| 904 | throw new IllegalArgumentException("Error reading entity ${entityName}: " + e.message) | ||
| 905 | } | ||
| 906 | } else { | ||
| 907 | throw new IllegalArgumentException("Unsupported resource URI: ${uri}") | ||
| 908 | } | ||
| 909 | } | ||
| 910 | |||
| 911 | // CORS handling based on MoquiServlet pattern | ||
| 912 | private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) { | ||
| 913 | String originHeader = request.getHeader("Origin") | ||
| 914 | if (originHeader) { | ||
| 915 | response.setHeader("Access-Control-Allow-Origin", originHeader) | ||
| 916 | response.setHeader("Access-Control-Allow-Credentials", "true") | ||
| 917 | } | ||
| 918 | |||
| 919 | String methodHeader = request.getHeader("Access-Control-Request-Method") | ||
| 920 | if (methodHeader) { | ||
| 921 | response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | ||
| 922 | response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept") | ||
| 923 | response.setHeader("Access-Control-Max-Age", "3600") | ||
| 924 | return true | ||
| 925 | } | ||
| 926 | return false | ||
| 927 | } | ||
| 928 | } | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -13,93 +13,11 @@ | ... | @@ -13,93 +13,11 @@ |
| 13 | */ | 13 | */ |
| 14 | package org.moqui.mcp | 14 | package org.moqui.mcp |
| 15 | 15 | ||
| 16 | import groovy.json.JsonBuilder | ||
| 17 | |||
| 18 | /** | 16 | /** |
| 19 | * MCP Transport interface compatible with Servlet 4.0 and Moqui Visit system | 17 | * Simple transport interface for MCP messages |
| 20 | * Provides SDK-style session management capabilities while maintaining compatibility | ||
| 21 | */ | 18 | */ |
| 22 | interface MoquiMcpTransport { | 19 | interface MoquiMcpTransport { |
| 23 | /** | 20 | void sendMessage(JsonRpcMessage message) |
| 24 | * Send a JSON-RPC message through this transport | ||
| 25 | * @param message The MCP JSON-RPC message to send | ||
| 26 | */ | ||
| 27 | void sendMessage(McpSchema.JSONRPCMessage message) | ||
| 28 | |||
| 29 | /** | ||
| 30 | * Close the transport gracefully, allowing in-flight messages to complete | ||
| 31 | */ | ||
| 32 | void closeGracefully() | ||
| 33 | |||
| 34 | /** | ||
| 35 | * Force close the transport immediately | ||
| 36 | */ | ||
| 37 | void close() | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Check if the transport is still active | ||
| 41 | * @return true if transport is active, false otherwise | ||
| 42 | */ | ||
| 43 | boolean isActive() | 21 | boolean isActive() |
| 44 | |||
| 45 | /** | ||
| 46 | * Get the session ID associated with this transport | ||
| 47 | * @return the MCP session ID | ||
| 48 | */ | ||
| 49 | String getSessionId() | 22 | String getSessionId() |
| 50 | |||
| 51 | /** | ||
| 52 | * Get the associated Moqui Visit ID | ||
| 53 | * @return the Visit ID if available, null otherwise | ||
| 54 | */ | ||
| 55 | String getVisitId() | ||
| 56 | } | ||
| 57 | |||
| 58 | /** | ||
| 59 | * Simple implementation of MCP JSON-RPC message schema | ||
| 60 | * Compatible with MCP protocol specifications | ||
| 61 | */ | ||
| 62 | class McpSchema { | ||
| 63 | static class JSONRPCMessage { | ||
| 64 | String jsonrpc = "2.0" | ||
| 65 | Object id | ||
| 66 | String method | ||
| 67 | Map params | ||
| 68 | Object result | ||
| 69 | Map error | ||
| 70 | |||
| 71 | JSONRPCMessage(String method, Map params = null, Object id = null) { | ||
| 72 | this.method = method | ||
| 73 | this.params = params | ||
| 74 | this.id = id | ||
| 75 | } | ||
| 76 | |||
| 77 | JSONRPCMessage(Object result, Object id) { | ||
| 78 | this.result = result | ||
| 79 | this.id = id | ||
| 80 | } | ||
| 81 | |||
| 82 | JSONRPCMessage(Map error, Object id) { | ||
| 83 | this.error = error | ||
| 84 | this.id = id | ||
| 85 | } | ||
| 86 | |||
| 87 | String toJson() { | ||
| 88 | return new JsonBuilder(this).toString() | ||
| 89 | } | ||
| 90 | |||
| 91 | static JSONRPCMessage fromJson(String json) { | ||
| 92 | // Simple JSON parsing - in production would use proper JSON parser | ||
| 93 | def slurper = new groovy.json.JsonSlurper() | ||
| 94 | def data = slurper.parseText(json) | ||
| 95 | |||
| 96 | if (data.error) { | ||
| 97 | return new JSONRPCMessage(data.error, data.id) | ||
| 98 | } else if (data.result != null) { | ||
| 99 | return new JSONRPCMessage(data.result, data.id) | ||
| 100 | } else { | ||
| 101 | return new JSONRPCMessage(data.method, data.params, data.id) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | } | ||
| 105 | } | 23 | } |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -258,15 +258,18 @@ class ServiceBasedMcpServlet extends HttpServlet { | ... | @@ -258,15 +258,18 @@ class ServiceBasedMcpServlet extends HttpServlet { |
| 258 | 258 | ||
| 259 | logger.info("Service-Based MCP Message authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}") | 259 | logger.info("Service-Based MCP Message authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}") |
| 260 | 260 | ||
| 261 | // If no user authenticated, try to authenticate as admin for MCP requests | 261 | // Require authentication - do not fallback to admin |
| 262 | if (!ec.user?.userId) { | 262 | if (!ec.user?.userId) { |
| 263 | logger.info("No user authenticated, attempting admin login for Service-Based MCP") | 263 | logger.warn("Service-Based MCP Request denied - no authenticated user") |
| 264 | try { | 264 | // Handle error directly without sendError to avoid Moqui error screen interference |
| 265 | ec.user.loginUser("admin", "admin") | 265 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) |
| 266 | logger.info("Service-Based MCP Admin login successful, user: ${ec.user?.username}") | 266 | response.setContentType("application/json") |
| 267 | } catch (Exception e) { | 267 | response.writer.write(groovy.json.JsonOutput.toJson([ |
| 268 | logger.warn("Service-Based MCP Admin login failed: ${e.message}") | 268 | jsonrpc: "2.0", |
| 269 | } | 269 | error: [code: -32000, message: "Authentication required. Please provide valid credentials."], |
| 270 | id: null | ||
| 271 | ])) | ||
| 272 | return | ||
| 270 | } | 273 | } |
| 271 | 274 | ||
| 272 | // Handle different HTTP methods | 275 | // Handle different HTTP methods |
| ... | @@ -435,15 +438,18 @@ class ServiceBasedMcpServlet extends HttpServlet { | ... | @@ -435,15 +438,18 @@ class ServiceBasedMcpServlet extends HttpServlet { |
| 435 | // Initialize web facade for authentication | 438 | // Initialize web facade for authentication |
| 436 | ec.initWebFacade(webappName, request, response) | 439 | ec.initWebFacade(webappName, request, response) |
| 437 | 440 | ||
| 438 | // If no user authenticated, try to authenticate as admin for MCP requests | 441 | // Require authentication - do not fallback to admin |
| 439 | if (!ec.user?.userId) { | 442 | if (!ec.user?.userId) { |
| 440 | logger.info("No user authenticated, attempting admin login for Legacy MCP") | 443 | logger.warn("Legacy MCP Request denied - no authenticated user") |
| 441 | try { | 444 | // Handle error directly without sendError to avoid Moqui error screen interference |
| 442 | ec.user.loginUser("admin", "admin") | 445 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) |
| 443 | logger.info("Legacy MCP Admin login successful, user: ${ec.user?.username}") | 446 | response.setContentType("application/json") |
| 444 | } catch (Exception e) { | 447 | response.writer.write(groovy.json.JsonOutput.toJson([ |
| 445 | logger.warn("Legacy MCP Admin login failed: ${e.message}") | 448 | jsonrpc: "2.0", |
| 446 | } | 449 | error: [code: -32000, message: "Authentication required. Please provide valid credentials."], |
| 450 | id: null | ||
| 451 | ])) | ||
| 452 | return | ||
| 447 | } | 453 | } |
| 448 | 454 | ||
| 449 | // Read and parse JSON-RPC request (same as POST handling) | 455 | // Read and parse JSON-RPC request (same as POST handling) | ... | ... |
| ... | @@ -73,7 +73,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -73,7 +73,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | @Override | 75 | @Override |
| 76 | void sendMessage(McpSchema.JSONRPCMessage message) { | 76 | void sendMessage(JsonRpcMessage message) { |
| 77 | if (!active.get() || closing.get()) { | 77 | if (!active.get() || closing.get()) { |
| 78 | logger.warn("Attempted to send message on inactive or closing session ${sessionId}") | 78 | logger.warn("Attempted to send message on inactive or closing session ${sessionId}") |
| 79 | return | 79 | return |
| ... | @@ -95,7 +95,6 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -95,7 +95,6 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 95 | } | 95 | } |
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | @Override | ||
| 99 | void closeGracefully() { | 98 | void closeGracefully() { |
| 100 | if (!active.compareAndSet(true, false)) { | 99 | if (!active.compareAndSet(true, false)) { |
| 101 | return // Already closed | 100 | return // Already closed |
| ... | @@ -106,11 +105,10 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -106,11 +105,10 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 106 | 105 | ||
| 107 | try { | 106 | try { |
| 108 | // Send graceful shutdown notification | 107 | // Send graceful shutdown notification |
| 109 | def shutdownMessage = new McpSchema.JSONRPCMessage([ | 108 | def shutdownMessage = new JsonRpcNotification("shutdown", [ |
| 110 | type: "shutdown", | ||
| 111 | sessionId: sessionId, | 109 | sessionId: sessionId, |
| 112 | timestamp: System.currentTimeMillis() | 110 | timestamp: System.currentTimeMillis() |
| 113 | ], null) | 111 | ]) |
| 114 | sendMessage(shutdownMessage) | 112 | sendMessage(shutdownMessage) |
| 115 | 113 | ||
| 116 | // Give some time for message to be sent | 114 | // Give some time for message to be sent |
| ... | @@ -123,7 +121,6 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -123,7 +121,6 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 123 | } | 121 | } |
| 124 | } | 122 | } |
| 125 | 123 | ||
| 126 | @Override | ||
| 127 | void close() { | 124 | void close() { |
| 128 | if (!active.compareAndSet(true, false)) { | 125 | if (!active.compareAndSet(true, false)) { |
| 129 | return // Already closed | 126 | return // Already closed |
| ... | @@ -160,7 +157,6 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -160,7 +157,6 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 160 | return sessionId | 157 | return sessionId |
| 161 | } | 158 | } |
| 162 | 159 | ||
| 163 | @Override | ||
| 164 | String getVisitId() { | 160 | String getVisitId() { |
| 165 | return visitId | 161 | return visitId |
| 166 | } | 162 | } | ... | ... |
| ... | @@ -21,18 +21,9 @@ | ... | @@ -21,18 +21,9 @@ |
| 21 | 21 | ||
| 22 | <!-- Service-Based MCP Servlet Configuration --> | 22 | <!-- Service-Based MCP Servlet Configuration --> |
| 23 | <servlet> | 23 | <servlet> |
| 24 | <servlet-name>ServiceBasedMcpServlet</servlet-name> | 24 | <servlet-name>EnhancedMcpServlet</servlet-name> |
| 25 | <servlet-class>org.moqui.mcp.ServiceBasedMcpServlet</servlet-class> | 25 | <servlet-class>org.moqui.mcp.EnhancedMcpServlet</servlet-class> |
| 26 | 26 | ||
| 27 | <!-- Configuration Parameters --> | ||
| 28 | <init-param> | ||
| 29 | <param-name>sseEndpoint</param-name> | ||
| 30 | <param-value>/sse</param-value> | ||
| 31 | </init-param> | ||
| 32 | <init-param> | ||
| 33 | <param-name>messageEndpoint</param-name> | ||
| 34 | <param-value>/mcp/message</param-value> | ||
| 35 | </init-param> | ||
| 36 | <init-param> | 27 | <init-param> |
| 37 | <param-name>keepAliveIntervalSeconds</param-name> | 28 | <param-name>keepAliveIntervalSeconds</param-name> |
| 38 | <param-value>30</param-value> | 29 | <param-value>30</param-value> |
| ... | @@ -49,20 +40,9 @@ | ... | @@ -49,20 +40,9 @@ |
| 49 | <load-on-startup>5</load-on-startup> | 40 | <load-on-startup>5</load-on-startup> |
| 50 | </servlet> | 41 | </servlet> |
| 51 | 42 | ||
| 52 | <!-- Servlet Mappings --> | ||
| 53 | <servlet-mapping> | 43 | <servlet-mapping> |
| 54 | <servlet-name>ServiceBasedMcpServlet</servlet-name> | 44 | <servlet-name>EnhancedMcpServlet</servlet-name> |
| 55 | <url-pattern>/sse/*</url-pattern> | 45 | <url-pattern>/mcp/*</url-pattern> |
| 56 | </servlet-mapping> | ||
| 57 | |||
| 58 | <servlet-mapping> | ||
| 59 | <servlet-name>ServiceBasedMcpServlet</servlet-name> | ||
| 60 | <url-pattern>/mcp/message/*</url-pattern> | ||
| 61 | </servlet-mapping> | ||
| 62 | |||
| 63 | <servlet-mapping> | ||
| 64 | <servlet-name>ServiceBasedMcpServlet</servlet-name> | ||
| 65 | <url-pattern>/rpc/*</url-pattern> | ||
| 66 | </servlet-mapping> | 46 | </servlet-mapping> |
| 67 | 47 | ||
| 68 | <!-- Session Configuration --> | 48 | <!-- Session Configuration --> | ... | ... |
web-sse.xml
deleted
100644 → 0
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" | ||
| 3 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| 4 | xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee | ||
| 5 | http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" | ||
| 6 | version="4.0"> | ||
| 7 | |||
| 8 | <!-- MCP SSE Servlet Configuration --> | ||
| 9 | <servlet> | ||
| 10 | <servlet-name>EnhancedMcpServlet</servlet-name> | ||
| 11 | <servlet-class>org.moqui.mcp.EnhancedMcpServlet</servlet-class> | ||
| 12 | |||
| 13 | <!-- Configuration parameters --> | ||
| 14 | <init-param> | ||
| 15 | <param-name>moqui-name</param-name> | ||
| 16 | <param-value>moqui-mcp-2</param-value> | ||
| 17 | </init-param> | ||
| 18 | |||
| 19 | <init-param> | ||
| 20 | <param-name>sseEndpoint</param-name> | ||
| 21 | <param-value>/sse</param-value> | ||
| 22 | </init-param> | ||
| 23 | |||
| 24 | <init-param> | ||
| 25 | <param-name>messageEndpoint</param-name> | ||
| 26 | <param-value>/mcp/message</param-value> | ||
| 27 | </init-param> | ||
| 28 | |||
| 29 | <init-param> | ||
| 30 | <param-name>keepAliveIntervalSeconds</param-name> | ||
| 31 | <param-value>30</param-value> | ||
| 32 | </init-param> | ||
| 33 | |||
| 34 | <init-param> | ||
| 35 | <param-name>maxConnections</param-name> | ||
| 36 | <param-value>100</param-value> | ||
| 37 | </init-param> | ||
| 38 | |||
| 39 | <!-- Enable async support --> | ||
| 40 | <async-supported>true</async-supported> | ||
| 41 | </servlet> | ||
| 42 | |||
| 43 | <!-- Servlet mappings for MCP SSE endpoints --> | ||
| 44 | <servlet-mapping> | ||
| 45 | <servlet-name>EnhancedMcpServlet</servlet-name> | ||
| 46 | <url-pattern>/sse/*</url-pattern> | ||
| 47 | </servlet-mapping> | ||
| 48 | |||
| 49 | <servlet-mapping> | ||
| 50 | <servlet-name>EnhancedMcpServlet</servlet-name> | ||
| 51 | <url-pattern>/mcp/message/*</url-pattern> | ||
| 52 | </servlet-mapping> | ||
| 53 | |||
| 54 | <!-- Session configuration --> | ||
| 55 | <session-config> | ||
| 56 | <session-timeout>30</session-timeout> | ||
| 57 | <cookie-config> | ||
| 58 | <http-only>true</http-only> | ||
| 59 | <secure>false</secure> | ||
| 60 | </cookie-config> | ||
| 61 | </session-config> | ||
| 62 | |||
| 63 | </web-app> | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
-
Please register or sign in to post a comment