85397022 by Ean Schuessler

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
1 parent 3bf14fc2
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>
54 <servlet-name>ServiceBasedMcpServlet</servlet-name>
55 <url-pattern>/sse/*</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> 43 <servlet-mapping>
64 <servlet-name>ServiceBasedMcpServlet</servlet-name> 44 <servlet-name>EnhancedMcpServlet</servlet-name>
65 <url-pattern>/rpc/*</url-pattern> 45 <url-pattern>/mcp/*</url-pattern>
66 </servlet-mapping> 46 </servlet-mapping>
67 47
68 <!-- Session Configuration --> 48 <!-- Session Configuration -->
...@@ -106,4 +86,4 @@ ...@@ -106,4 +86,4 @@
106 <welcome-file>index.jsp</welcome-file> 86 <welcome-file>index.jsp</welcome-file>
107 </welcome-file-list> 87 </welcome-file-list>
108 88
109 </web-app>
...\ No newline at end of file ...\ No newline at end of file
89 </web-app>
......
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