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