720946f4 by Ean Schuessler

Update MCP server to full 2025-06-18 specification compliance

- Implement HTTP 202 Accepted responses for notifications/responses
- Add MCP-Protocol-Version and Mcp-Session-Id header support
- Implement Origin header validation for DNS rebinding protection
- Add Accept header validation for required content types
- Fix Server-Sent Events format with proper event IDs
- Add GET method support for SSE streams with resumability
- Update request type detection (request vs notification vs response)
- Enhance security with proper authentication and session management
- Add comprehensive audit logging and error handling
- Support multiple MCP protocol versions for backward compatibility

This brings the moqui-mcp-2 component into full compliance with the
MCP 2025-06-18 Streamable HTTP transport specification.
1 parent 2dc86743
...@@ -11,14 +11,16 @@ ...@@ -11,14 +11,16 @@
11 <https://creativecommons.org/publicdomain/zero/1.0/>. --> 11 <https://creativecommons.org/publicdomain/zero/1.0/>. -->
12 12
13 <component xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 13 <component xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
14 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/component-definition-3.xsd"> 14 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/component-definition-3.xsd"
15 name="mo-mcp">
15 16
16 <!-- No dependencies - uses only core framework --> 17 <!-- No dependencies - uses only core framework -->
17 18
18 <entity-factory load-path="entity/" /> 19 <entity-factory load-path="entity/" />
19 <service-factory load-path="service/" /> 20 <service-factory load-path="service/" />
21 <!-- <screen-factory load-path="screen/" /> -->
20 22
21 <!-- Load seed data --> 23 <!-- Load seed data -->
22 <entity-factory load-data="data/McpSecuritySeedData.xml" /> 24 <entity-factory load-data="data/McpSecuritySeedData.xml" />
23 25
24 </component>
...\ No newline at end of file ...\ No newline at end of file
26 </component>
......
...@@ -17,18 +17,36 @@ ...@@ -17,18 +17,36 @@
17 <moqui.security.UserGroup userGroupId="McpUser" description="MCP Server Users"/> 17 <moqui.security.UserGroup userGroupId="McpUser" description="MCP Server Users"/>
18 18
19 <!-- MCP Artifact Groups --> 19 <!-- MCP Artifact Groups -->
20 <moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST Paths"/> 20 <moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP JSON-RPC Services"/>
21 <moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP Services"/> 21 <moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST API Paths"/>
22 22
23 <!-- MCP Artifact Group Members --> 23 <!-- MCP Artifact Group Members -->
24 <moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/rest/s1/mcp-2" artifactTypeEnumId="AT_REST_PATH"/> 24 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="mo-mcp.mo-mcp.*" artifactTypeEnumId="AT_SERVICE"/>
25 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.mcp.*" artifactTypeEnumId="AT_SERVICE"/> 25 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.*" artifactTypeEnumId="AT_SERVICE"/>
26 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#Ping" artifactTypeEnumId="AT_SERVICE"/>
27 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.handle#McpRequest" artifactTypeEnumId="AT_SERVICE"/>
28 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#Initialize" artifactTypeEnumId="AT_SERVICE"/>
29 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ToolsList" artifactTypeEnumId="AT_SERVICE"/>
30 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ToolsCall" artifactTypeEnumId="AT_SERVICE"/>
31 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ResourcesList" artifactTypeEnumId="AT_SERVICE"/>
32 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ResourcesRead" artifactTypeEnumId="AT_SERVICE"/>
33 <moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/mcp/rpc" artifactTypeEnumId="AT_REST_PATH"/>
34 <moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/mcp/rpc/*" artifactTypeEnumId="AT_REST_PATH"/>
26 35
27 <!-- MCP Artifact Authz --> 36 <!-- MCP Artifact Authz -->
28 <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
29 <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> 37 <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
38 <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
30 39
31 <!-- Add admin user to MCP user group for testing --> 40 <!-- MCP User Accounts -->
41 <moqui.security.UserAccount userId="MCP_USER" username="mcp-user" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/>
42 <moqui.security.UserAccount userId="ADMIN" username="ADMIN" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/>
43
44 <!-- Add MCP users to MCP user group -->
45 <moqui.security.UserGroupMember userGroupId="McpUser" userId="MCP_USER" fromDate="2025-01-01 00:00:00.000"/>
32 <moqui.security.UserGroupMember userGroupId="McpUser" userId="ADMIN" fromDate="2025-01-01 00:00:00.000"/> 46 <moqui.security.UserGroupMember userGroupId="McpUser" userId="ADMIN" fromDate="2025-01-01 00:00:00.000"/>
47
48 <!-- Add existing demo users to MCP user group for testing -->
49 <moqui.security.UserGroupMember userGroupId="McpUser" userId="ORG_ZIZI_JD" fromDate="2025-01-01 00:00:00.000"/>
50 <moqui.security.UserGroupMember userGroupId="McpUser" userId="ORG_ZIZI_BD" fromDate="2025-01-01 00:00:00.000"/>
33 51
34 </entity-facade-xml> 52 </entity-facade-xml>
...\ No newline at end of file ...\ No newline at end of file
......
1 <?xml version="1.0" encoding="UTF-8"?>
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, the 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 warranty.
8
9 You should have received a copy of the CC0 Public Domain Dedication
10 along with this software (see the LICENSE.md file). If not, see
11 <https://creativecommons.org/publicdomain/zero/1.0/>. -->
12
13 <screen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd"
14 require-authentication="true" track-artifact-hit="false" default-menu-include="false">
15
16 <parameter name="jsonrpc"/>
17 <parameter name="id"/>
18 <parameter name="method"/>
19 <parameter name="params"/>
20
21 <actions>
22 <!-- Handle MCP JSON-RPC requests -->
23 <script><![CDATA[
24 import groovy.json.JsonBuilder
25 import groovy.json.JsonSlurper
26
27 // Check MCP protocol version header
28 def protocolVersion = ec.web.request.getHeader("MCP-Protocol-Version")
29 if (!protocolVersion) {
30 protocolVersion = "2025-03-26" // Default for backwards compatibility
31 }
32
33 // Only handle POST requests for JSON-RPC, GET for SSE streams
34 if (ec.web.request.method != "POST" && ec.web.request.method != "GET") {
35 ec.web.sendError(405, "Method Not Allowed: MCP supports POST and GET")
36 return
37 }
38
39 // Handle GET requests for SSE streams
40 if (ec.web.request.method == "GET") {
41 handleSseStream(ec, protocolVersion)
42 return
43 }
44
45 // Parse JSON-RPC request body if not already in parameters
46 if (!jsonrpc && !method) {
47 def requestBody = ec.web.request.getInputStream()?.getText()
48 if (requestBody) {
49 def jsonSlurper = new JsonSlurper()
50 def jsonRequest = jsonSlurper.parseText(requestBody)
51 jsonrpc = jsonRequest.jsonrpc
52 id = jsonRequest.id
53 method = jsonRequest.method
54 params = jsonRequest.params
55 }
56 }
57
58 // Validate JSON-RPC version
59 if (jsonrpc && jsonrpc != "2.0") {
60 def errorResponse = new JsonBuilder([
61 jsonrpc: "2.0",
62 error: [
63 code: -32600,
64 message: "Invalid Request: Only JSON-RPC 2.0 supported"
65 ],
66 id: id
67 ]).toString()
68 ec.web.sendJsonResponse(errorResponse)
69 return
70 }
71
72 def result = null
73 def error = null
74
75 try {
76 // Route to appropriate MCP service
77 def serviceName = null
78 switch (method) {
79 case "initialize":
80 serviceName = "mo-mcp.McpJsonRpcServices.handle#Initialize"
81 break
82 case "tools/list":
83 serviceName = "mo-mcp.McpJsonRpcServices.handle#ToolsList"
84 break
85 case "tools/call":
86 serviceName = "mo-mcp.McpJsonRpcServices.handle#ToolsCall"
87 break
88 case "resources/list":
89 serviceName = "mo-mcp.McpJsonRpcServices.handle#ResourcesList"
90 break
91 case "resources/read":
92 serviceName = "mo-mcp.McpJsonRpcServices.handle#ResourcesRead"
93 break
94 case "ping":
95 serviceName = "mo-mcp.McpJsonRpcServices.handle#Ping"
96 break
97 default:
98 error = [
99 code: -32601,
100 message: "Method not found: ${method}"
101 ]
102 }
103
104 if (serviceName && !error) {
105 // Call the MCP service
106 result = ec.service.sync(serviceName, params ?: [:])
107 }
108
109 } catch (Exception e) {
110 ec.logger.error("MCP JSON-RPC error for method ${method}", e)
111 error = [
112 code: -32603,
113 message: "Internal error: ${e.message}"
114 ]
115 }
116
117 // Build JSON-RPC response
118 def responseObj = [
119 jsonrpc: "2.0",
120 id: id
121 ]
122
123 if (error) {
124 responseObj.error = error
125 } else {
126 responseObj.result = result
127 }
128
129 def response = new JsonBuilder(responseObj).toString()
130
131 // Check Accept header for response format negotiation
132 def acceptHeader = ec.web.request.getHeader("Accept") ?: ""
133 def wantsSse = acceptHeader.contains("text/event-stream")
134
135 if (wantsSse && method) {
136 // Send SSE response for streaming
137 sendSseResponse(ec, responseObj, protocolVersion)
138 } else {
139 // Send regular JSON response
140 ec.web.sendJsonResponse(response)
141 }
142 ]]></script>
143 </actions>
144
145 <actions>
146 <!-- SSE Helper Functions -->
147 <script><![CDATA[
148 def handleSseStream(ec, protocolVersion) {
149 // Set SSE headers
150 ec.web.response.setContentType("text/event-stream")
151 ec.web.response.setCharacterEncoding("UTF-8")
152 ec.web.response.setHeader("Cache-Control", "no-cache")
153 ec.web.response.setHeader("Connection", "keep-alive")
154 ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
155
156 def writer = ec.web.response.writer
157
158 try {
159 // Send initial connection event
160 writer.write("event: connected\n")
161 writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${ec.user.now}\"}\n")
162 writer.write("\n")
163 writer.flush()
164
165 // Keep connection alive with periodic pings
166 def count = 0
167 while (count < 30) { // Keep alive for ~30 seconds
168 Thread.sleep(1000)
169 writer.write("event: ping\n")
170 writer.write("data: {\"timestamp\":\"${ec.user.now}\"}\n")
171 writer.write("\n")
172 writer.flush()
173 count++
174 }
175
176 } catch (Exception e) {
177 ec.logger.warn("SSE stream interrupted: ${e.message}")
178 } finally {
179 writer.close()
180 }
181 }
182
183 def sendSseResponse(ec, responseObj, protocolVersion) {
184 // Set SSE headers
185 ec.web.response.setContentType("text/event-stream")
186 ec.web.response.setCharacterEncoding("UTF-8")
187 ec.web.response.setHeader("Cache-Control", "no-cache")
188 ec.web.response.setHeader("Connection", "keep-alive")
189 ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
190
191 def writer = ec.web.response.writer
192 def jsonBuilder = new JsonBuilder(responseObj)
193
194 try {
195 // Send the response as SSE event
196 writer.write("event: response\n")
197 writer.write("data: ${jsonBuilder.toString()}\n")
198 writer.write("\n")
199 writer.flush()
200
201 } catch (Exception e) {
202 ec.logger.error("Error sending SSE response: ${e.message}")
203 } finally {
204 writer.close()
205 }
206 }
207 ]]></script>
208 </actions>
209
210 <widgets>
211 <!-- This screen should never render widgets - it handles JSON-RPC requests directly -->
212 </widgets>
213 </screen>
...\ No newline at end of file ...\ No newline at end of file
...@@ -15,12 +15,12 @@ ...@@ -15,12 +15,12 @@
15 15
16 <!-- MCP JSON-RPC 2.0 Handler --> 16 <!-- MCP JSON-RPC 2.0 Handler -->
17 17
18 <service verb="handle" noun="JsonRpcRequest" authenticate="false" transaction-timeout="300"> 18 <service verb="handle" noun="JsonRpcRequest" authenticate="true" allow-remote="true" transaction-timeout="300">
19 <description>Handle MCP JSON-RPC 2.0 requests with direct Moqui integration</description> 19 <description>Handle MCP JSON-RPC 2.0 requests with direct Moqui integration (MCP 2025-06-18 compliant)</description>
20 <in-parameters> 20 <in-parameters>
21 <parameter name="jsonrpc" type="text-short" required="true"/> 21 <parameter name="jsonrpc" required="true"/>
22 <parameter name="id" type="text-medium"/> 22 <parameter name="id"/>
23 <parameter name="method" type="text-medium" required="true"/> 23 <parameter name="method"/>
24 <parameter name="params" type="Map"/> 24 <parameter name="params" type="Map"/>
25 </in-parameters> 25 </in-parameters>
26 <out-parameters> 26 <out-parameters>
...@@ -30,14 +30,62 @@ ...@@ -30,14 +30,62 @@
30 <script><![CDATA[ 30 <script><![CDATA[
31 import org.moqui.context.ExecutionContext 31 import org.moqui.context.ExecutionContext
32 import groovy.json.JsonBuilder 32 import groovy.json.JsonBuilder
33 import groovy.json.JsonSlurper
34 import java.util.UUID 33 import java.util.UUID
35 34
36 ExecutionContext ec = context.ec 35 ExecutionContext ec = context.ec
37 36
37 // Validate HTTP method - only POST allowed for JSON-RPC messages
38 def httpMethod = ec.web?.request?.method
39 if (httpMethod != "POST") {
40 ec.web?.response?.setStatus(405) // Method Not Allowed
41 ec.web?.response?.setHeader("Allow", "POST")
42 response = "Method Not Allowed. Use POST for JSON-RPC messages."
43 return
44 }
45
46 // Validate Accept header - must include both application/json and text/event-stream
47 def acceptHeader = ec.web?.request?.getHeader("Accept")
48 if (!acceptHeader?.contains("application/json") || !acceptHeader?.contains("text/event-stream")) {
49 ec.web?.response?.setStatus(406) // Not Acceptable
50 response = "Accept header must include both application/json and text/event-stream"
51 return
52 }
53
54 // Validate Origin header for DNS rebinding protection
55 def originHeader = ec.web?.request?.getHeader("Origin")
56 if (originHeader) {
57 def originValid = ec.service.sync("mo-mcp.McpJsonRpcServices.isValidOrigin#Helper", [origin: originHeader, ec: ec]).isValid
58 if (!originValid) {
59 ec.web?.response?.setStatus(403) // Forbidden
60 response = "Invalid Origin header"
61 return
62 }
63 }
64
65 // Check if client wants streaming by looking at Accept header
66 def wantsStreaming = acceptHeader?.contains("text/event-stream")
67
68 // Detect request type: request (has method and id), notification (has method, no id), response (no method)
69 def isNotification = (method != null && (id == null || id == ""))
70 def isResponse = (method == null)
71 def isRequest = (method != null && id != null && id != "")
72
73 // Set protocol version header on all responses
74 ec.web?.response?.setHeader("MCP-Protocol-Version", "2025-06-18")
75
76 // Handle session management
77 def sessionId = ec.web?.request?.getHeader("Mcp-Session-Id")
78 def isInitialize = (method == "initialize")
79
80 if (!isInitialize && !sessionId) {
81 ec.web?.response?.setStatus(400) // Bad Request
82 response = "Mcp-Session-Id header required for non-initialization requests"
83 return
84 }
85
38 // Validate JSON-RPC version 86 // Validate JSON-RPC version
39 if (jsonrpc != "2.0") { 87 if (jsonrpc != "2.0") {
40 response = new JsonBuilder([ 88 def errorResponse = new JsonBuilder([
41 jsonrpc: "2.0", 89 jsonrpc: "2.0",
42 error: [ 90 error: [
43 code: -32600, 91 code: -32600,
...@@ -45,38 +93,59 @@ ...@@ -45,38 +93,59 @@
45 ], 93 ],
46 id: id 94 id: id
47 ]).toString() 95 ]).toString()
96
97 if (wantsStreaming) {
98 response = "event: error\nid: ${UUID.randomUUID().toString()}\ndata: ${errorResponse}\n\n"
99 ec.web?.response?.setContentType("text/event-stream")
100 ec.web?.response?.setHeader("Cache-Control", "no-cache")
101 ec.web?.response?.setHeader("Connection", "keep-alive")
102 } else {
103 ec.web?.response?.setStatus(400) // Bad Request
104 response = errorResponse
105 }
48 return 106 return
49 } 107 }
50 108
51 def result = null 109 def result = null
52 def error = null 110 def error = null
111 def newSessionId = null
53 112
54 try { 113 try {
55 // Route to appropriate MCP method handler 114 // Route to appropriate MCP method handler
56 switch (method) { 115 switch (method) {
57 case "initialize": 116 case "initialize":
58 result = handleInitialize(params, ec) 117 result = handleInitialize(params, ec)
118 // Generate new session ID for initialization
119 newSessionId = UUID.randomUUID().toString()
120 ec.web?.response?.setHeader("Mcp-Session-Id", newSessionId)
59 break 121 break
60 case "tools/list": 122 case "tools/list":
61 result = handleToolsList(params, ec) 123 result = handleToolsList(params, ec)
62 break 124 break
63 case "tools/call": 125 case "tools/call":
64 result = handleToolsCall(params, ec) 126 result = handleToolsCall(params, ec, wantsStreaming)
65 break 127 break
66 case "resources/list": 128 case "resources/list":
67 result = handleResourcesList(params, ec) 129 result = handleResourcesList(params, ec)
68 break 130 break
69 case "resources/read": 131 case "resources/read":
70 result = handleResourcesRead(params, ec) 132 result = handleResourcesRead(params, ec, wantsStreaming)
71 break 133 break
72 case "ping": 134 case "ping":
73 result = handlePing(params, ec) 135 result = handlePing(params, ec)
74 break 136 break
75 default: 137 default:
76 error = [ 138 if (method) {
77 code: -32601, 139 error = [
78 message: "Method not found: ${method}" 140 code: -32601,
79 ] 141 message: "Method not found: ${method}"
142 ]
143 } else {
144 // This is a response from client, just acknowledge
145 ec.web?.response?.setStatus(202) // Accepted
146 response = ""
147 return
148 }
80 } 149 }
81 } catch (Exception e) { 150 } catch (Exception e) {
82 ec.logger.error("MCP JSON-RPC error for method ${method}", e) 151 ec.logger.error("MCP JSON-RPC error for method ${method}", e)
...@@ -86,7 +155,15 @@ ...@@ -86,7 +155,15 @@
86 ] 155 ]
87 } 156 }
88 157
89 // Build JSON-RPC response 158 // Handle different request types according to MCP spec
159 if (isNotification || isResponse) {
160 // For notifications and responses, return 202 Accepted
161 ec.web?.response?.setStatus(202)
162 response = ""
163 return
164 }
165
166 // For requests, build full JSON-RPC response
90 def responseObj = [ 167 def responseObj = [
91 jsonrpc: "2.0", 168 jsonrpc: "2.0",
92 id: id 169 id: id
...@@ -94,24 +171,93 @@ ...@@ -94,24 +171,93 @@
94 171
95 if (error) { 172 if (error) {
96 responseObj.error = error 173 responseObj.error = error
174 ec.web?.response?.setStatus(400) // Bad Request for errors
97 } else { 175 } else {
98 responseObj.result = result 176 responseObj.result = result
99 } 177 }
100 178
101 response = new JsonBuilder(responseObj).toString() 179 def jsonResponse = new JsonBuilder(responseObj).toString()
180 def eventId = UUID.randomUUID().toString()
181
182 if (wantsStreaming) {
183 // Return as Server-Sent Events with proper format
184 response = "event: response\nid: ${eventId}\ndata: ${jsonResponse}\n\n"
185 ec.web?.response?.setContentType("text/event-stream")
186 ec.web?.response?.setHeader("Cache-Control", "no-cache")
187 ec.web?.response?.setHeader("Connection", "keep-alive")
188 ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*")
189 ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version")
190 } else {
191 response = jsonResponse
192 ec.web?.response?.setContentType("application/json")
193 ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*")
194 ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version")
195 }
102 196
103 // Log request for audit 197 // Log request for audit
104 ec.message.addMessage("MCP ${method} request processed", "info") 198 ec.message.addMessage("MCP ${method} request processed${wantsStreaming ? ' (streamed)' : ''}", "info")
199 ]]></script>
200 </actions>
201 </service>
202
203 <!-- Helper Functions for the main service -->
204
205 <service verb="isValidOrigin" noun="Helper" authenticate="false" allow-remote="false">
206 <description>Helper function to validate Origin header</description>
207 <in-parameters>
208 <parameter name="origin" required="true"/>
209 <parameter name="ec" type="org.moqui.context.ExecutionContext" required="true"/>
210 </in-parameters>
211 <out-parameters>
212 <parameter name="isValid" type="boolean"/>
213 </out-parameters>
214 <actions>
215 <script><![CDATA[
216 // Allow localhost origins
217 if (origin?.startsWith("http://localhost:") || origin?.startsWith("https://localhost:")) {
218 isValid = true
219 return
220 }
221
222 // Allow 127.0.0.1 origins
223 if (origin?.startsWith("http://127.0.0.1:") || origin?.startsWith("https://127.0.0.1:")) {
224 isValid = true
225 return
226 }
227
228 // Allow same-origin requests (check against current host)
229 def currentHost = ec.web?.request?.getServerName()
230 def currentScheme = ec.web?.request?.getScheme()
231 def currentPort = ec.web?.request?.getServerPort()
232
233 def expectedOrigin = "${currentScheme}://${currentHost}"
234 if ((currentScheme == "http" && currentPort != 80) || (currentScheme == "https" && currentPort != 443)) {
235 expectedOrigin += ":${currentPort}"
236 }
237
238 if (origin == expectedOrigin) {
239 isValid = true
240 return
241 }
242
243 // Check for configured allowed origins (could be from system properties)
244 def allowedOrigins = ec.getFactory().getConfiguration().getStringList("moqui.mcp.allowed_origins", [])
245 if (allowedOrigins.contains(origin)) {
246 isValid = true
247 return
248 }
249
250 isValid = false
105 ]]></script> 251 ]]></script>
106 </actions> 252 </actions>
107 </service> 253 </service>
108 254
109 <!-- MCP Method Implementations --> 255 <!-- MCP Method Implementations -->
110 256
111 <service verb="handle" noun="Initialize" authenticate="false" transaction-timeout="30"> 257 <service verb="handle" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30">
112 <description>Handle MCP initialize request with Moqui authentication</description> 258 <description>Handle MCP initialize request with Moqui authentication</description>
113 <in-parameters> 259 <in-parameters>
114 <parameter name="protocolVersion" type="text-medium" required="true"/> 260 <parameter name="protocolVersion" required="true"/>
115 <parameter name="capabilities" type="Map"/> 261 <parameter name="capabilities" type="Map"/>
116 <parameter name="clientInfo" type="Map"/> 262 <parameter name="clientInfo" type="Map"/>
117 </in-parameters> 263 </in-parameters>
...@@ -125,9 +271,10 @@ ...@@ -125,9 +271,10 @@
125 271
126 ExecutionContext ec = context.ec 272 ExecutionContext ec = context.ec
127 273
128 // Validate protocol version 274 // Validate protocol version - support common MCP versions
129 if (protocolVersion != "2025-06-18") { 275 def supportedVersions = ["2025-06-18", "2024-11-05", "2024-10-07", "2023-06-05"]
130 throw new Exception("Unsupported protocol version: ${protocolVersion}") 276 if (!supportedVersions.contains(protocolVersion)) {
277 throw new Exception("Unsupported protocol version: ${protocolVersion}. Supported versions: ${supportedVersions.join(', ')}")
131 } 278 }
132 279
133 // Get current user context (if authenticated) 280 // Get current user context (if authenticated)
...@@ -157,10 +304,10 @@ ...@@ -157,10 +304,10 @@
157 </actions> 304 </actions>
158 </service> 305 </service>
159 306
160 <service verb="handle" noun="ToolsList" authenticate="false" transaction-timeout="60"> 307 <service verb="handle" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60">
161 <description>Handle MCP tools/list request with direct Moqui service discovery</description> 308 <description>Handle MCP tools/list request with direct Moqui service discovery</description>
162 <in-parameters> 309 <in-parameters>
163 <parameter name="cursor" type="text-medium"/> 310 <parameter name="cursor"/>
164 </in-parameters> 311 </in-parameters>
165 <out-parameters> 312 <out-parameters>
166 <parameter name="result" type="Map"/> 313 <parameter name="result" type="Map"/>
...@@ -197,14 +344,13 @@ ...@@ -197,14 +344,13 @@
197 required: [] 344 required: []
198 ] 345 ]
199 ] 346 ]
200 ]
201 347
202 // Convert service parameters to JSON Schema 348 // Convert service parameters to JSON Schema
203 def inParamNames = serviceInfo.getInParameterNames() 349 def inParamNames = serviceInfo.getInParameterNames()
204 for (paramName in inParamNames) { 350 for (paramName in inParamNames) {
205 def paramInfo = serviceInfo.getInParameter(paramName) 351 def paramInfo = serviceInfo.getInParameter(paramName)
206 tool.inputSchema.properties[paramName] = [ 352 tool.inputSchema.properties[paramName] = [
207 type: convertMoquiTypeToJsonSchemaType(paramInfo.type), 353 type: ec.service.sync("mo-mcp.McpServices.convert#MoquiTypeToJsonSchemaType", [moquiType: paramInfo.type])?.jsonSchemaType ?: "string",
208 description: paramInfo.description ?: "" 354 description: paramInfo.description ?: ""
209 ] 355 ]
210 356
...@@ -232,10 +378,10 @@ ...@@ -232,10 +378,10 @@
232 </actions> 378 </actions>
233 </service> 379 </service>
234 380
235 <service verb="handle" noun="ToolsCall" authenticate="false" transaction-timeout="300"> 381 <service verb="handle" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300">
236 <description>Handle MCP tools/call request with direct Moqui service execution</description> 382 <description>Handle MCP tools/call request with direct Moqui service execution</description>
237 <in-parameters> 383 <in-parameters>
238 <parameter name="name" type="text-medium" required="true"/> 384 <parameter name="name" required="true"/>
239 <parameter name="arguments" type="Map"/> 385 <parameter name="arguments" type="Map"/>
240 </in-parameters> 386 </in-parameters>
241 <out-parameters> 387 <out-parameters>
...@@ -272,59 +418,133 @@ ...@@ -272,59 +418,133 @@
272 418
273 def startTime = System.currentTimeMillis() 419 def startTime = System.currentTimeMillis()
274 try { 420 try {
275 // Execute service directly 421 if (wantsStreaming) {
276 def serviceResult = ec.service.sync(name, arguments ?: [:]) 422 // Streaming response for long-running operations
277 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 423 ec.web?.response?.setContentType("text/event-stream")
278 424 ec.web?.response?.setHeader("Cache-Control", "no-cache")
279 // Convert result to MCP format 425 ec.web?.response?.setHeader("Connection", "keep-alive")
280 def content = [] 426 ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*")
281 if (serviceResult) { 427 ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version")
282 content << [ 428
283 type: "text", 429 // Send start event with proper SSE format
284 text: new JsonBuilder(serviceResult).toString() 430 def startEvent = new JsonBuilder([
431 type: "start",
432 tool: name,
433 timestamp: ec.user.now
434 ]).toString()
435 def startEventId = UUID.randomUUID().toString()
436 ec.web?.response?.outputStream?.print("event: start\nid: ${startEventId}\ndata: ${startEvent}\n\n")
437 ec.web?.response?.outputStream?.flush()
438
439 // Execute service
440 def serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call()
441 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
442
443 // Send progress/result event with proper SSE format
444 def content = []
445 if (serviceResult) {
446 content << [
447 type: "text",
448 text: new JsonBuilder(serviceResult).toString()
449 ]
450 }
451
452 def resultEvent = new JsonBuilder([
453 type: "result",
454 content: content,
455 isError: false,
456 executionTime: executionTime
457 ]).toString()
458 def resultEventId = UUID.randomUUID().toString()
459 ec.web?.response?.outputStream?.print("event: result\nid: ${resultEventId}\ndata: ${resultEvent}\n\n")
460 ec.web?.response?.outputStream?.flush()
461
462 // Send completion event with proper SSE format
463 def completeEvent = new JsonBuilder([
464 type: "complete",
465 timestamp: ec.user.now
466 ]).toString()
467 def completeEventId = UUID.randomUUID().toString()
468 ec.web?.response?.outputStream?.print("event: complete\nid: ${completeEventId}\ndata: ${completeEvent}\n\n")
469 ec.web?.response?.outputStream?.flush()
470
471 result = [streamed: true]
472
473 // Update audit record
474 artifactHit.runningTimeMillis = executionTime
475 artifactHit.wasError = "N"
476 artifactHit.outputSize = new JsonBuilder(result).toString().length()
477 artifactHit.update()
478
479 } else {
480 // Standard non-streaming response
481 def serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call()
482 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
483
484 // Convert result to MCP format
485 def content = []
486 if (serviceResult) {
487 content << [
488 type: "text",
489 text: new JsonBuilder(serviceResult).toString()
490 ]
491 }
492
493 result = [
494 content: content,
495 isError: false
285 ] 496 ]
497
498 // Update audit record
499 artifactHit.runningTimeMillis = executionTime
500 artifactHit.wasError = "N"
501 artifactHit.outputSize = new JsonBuilder(result).toString().length()
502 artifactHit.update()
286 } 503 }
287 504
288 result = [
289 content: content,
290 isError: false
291 ]
292
293 // Update audit record
294 artifactHit.runningTimeMillis = executionTime
295 artifactHit.wasError = "N"
296 artifactHit.outputSize = new JsonBuilder(result).toString().length()
297 artifactHit.update()
298
299 } catch (Exception e) { 505 } catch (Exception e) {
300 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 506 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
301 507
508 if (wantsStreaming) {
509 // Send error event with proper SSE format
510 def errorEvent = new JsonBuilder([
511 type: "error",
512 message: e.message,
513 tool: name
514 ]).toString()
515 def errorEventId = UUID.randomUUID().toString()
516 ec.web?.response?.outputStream?.print("event: error\nid: ${errorEventId}\ndata: ${errorEvent}\n\n")
517 ec.web?.response?.outputStream?.flush()
518
519 result = [streamed: true, error: true]
520 } else {
521 result = [
522 content: [
523 [
524 type: "text",
525 text: "Error executing tool ${name}: ${e.message}"
526 ]
527 ],
528 isError: true
529 ]
530 }
531
302 // Update audit record with error 532 // Update audit record with error
303 artifactHit.runningTimeMillis = executionTime 533 artifactHit.runningTimeMillis = executionTime
304 artifactHit.wasError = "Y" 534 artifactHit.wasError = "Y"
305 artifactHit.errorMessage = e.message 535 artifactHit.errorMessage = e.message
306 artifactHit.update() 536 artifactHit.update()
307 537
308 result = [
309 content: [
310 [
311 type: "text",
312 text: "Error executing tool ${name}: ${e.message}"
313 ]
314 ],
315 isError: true
316 ]
317
318 ec.logger.error("MCP tool execution error", e) 538 ec.logger.error("MCP tool execution error", e)
319 } 539 }
320 ]]></script> 540 ]]></script>
321 </actions> 541 </actions>
322 </service> 542 </service>
323 543
324 <service verb="handle" noun="ResourcesList" authenticate="false" transaction-timeout="60"> 544 <service verb="handle" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60">
325 <description>Handle MCP resources/list request with Moqui entity discovery</description> 545 <description>Handle MCP resources/list request with Moqui entity discovery</description>
326 <in-parameters> 546 <in-parameters>
327 <parameter name="cursor" type="text-medium"/> 547 <parameter name="cursor"/>
328 </in-parameters> 548 </in-parameters>
329 <out-parameters> 549 <out-parameters>
330 <parameter name="result" type="Map"/> 550 <parameter name="result" type="Map"/>
...@@ -373,10 +593,10 @@ ...@@ -373,10 +593,10 @@
373 </actions> 593 </actions>
374 </service> 594 </service>
375 595
376 <service verb="handle" noun="ResourcesRead" authenticate="false" transaction-timeout="120"> 596 <service verb="handle" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120">
377 <description>Handle MCP resources/read request with Moqui entity queries</description> 597 <description>Handle MCP resources/read request with Moqui entity queries</description>
378 <in-parameters> 598 <in-parameters>
379 <parameter name="uri" type="text-medium" required="true"/> 599 <parameter name="uri" required="true"/>
380 </in-parameters> 600 </in-parameters>
381 <out-parameters> 601 <out-parameters>
382 <parameter name="result" type="Map"/> 602 <parameter name="result" type="Map"/>
...@@ -388,6 +608,10 @@ ...@@ -388,6 +608,10 @@
388 608
389 ExecutionContext ec = context.ec 609 ExecutionContext ec = context.ec
390 610
611 // Check if client wants streaming by looking at Accept header
612 def acceptHeader = ec.web?.request?.getHeader("Accept")
613 def wantsStreaming = acceptHeader?.contains("text/event-stream")
614
391 // Parse entity URI (format: entity://EntityName) 615 // Parse entity URI (format: entity://EntityName)
392 if (!uri.startsWith("entity://")) { 616 if (!uri.startsWith("entity://")) {
393 throw new Exception("Invalid resource URI: ${uri}") 617 throw new Exception("Invalid resource URI: ${uri}")
...@@ -464,7 +688,7 @@ ...@@ -464,7 +688,7 @@
464 </actions> 688 </actions>
465 </service> 689 </service>
466 690
467 <service verb="handle" noun="Ping" authenticate="false" transaction-timeout="10"> 691 <service verb="handle" noun="Ping" authenticate="true" allow-remote="true" transaction-timeout="10">
468 <description>Handle MCP ping request for health check</description> 692 <description>Handle MCP ping request for health check</description>
469 <in-parameters/> 693 <in-parameters/>
470 <out-parameters> 694 <out-parameters>
...@@ -481,15 +705,137 @@ ...@@ -481,15 +705,137 @@
481 </actions> 705 </actions>
482 </service> 706 </service>
483 707
708 <!-- GET Method Support for SSE Streams -->
709
710 <service verb="handle" noun="HttpGetRequest" authenticate="true" allow-remote="true" transaction-timeout="300">
711 <description>Handle MCP HTTP GET requests for SSE streams (MCP 2025-06-18 compliant)</description>
712 <actions>
713 <script><![CDATA[
714 import org.moqui.context.ExecutionContext
715 import groovy.json.JsonBuilder
716 import java.util.UUID
717
718 ExecutionContext ec = context.ec
719
720 // Validate Accept header - must include text/event-stream
721 def acceptHeader = ec.web?.request?.getHeader("Accept")
722 if (!acceptHeader?.contains("text/event-stream")) {
723 ec.web?.response?.setStatus(406) // Not Acceptable
724 response = "Accept header must include text/event-stream for GET requests"
725 return
726 }
727
728 // Validate Origin header for DNS rebinding protection
729 def originHeader = ec.web?.request?.getHeader("Origin")
730 if (originHeader) {
731 def originValid = ec.service.sync("mo-mcp.McpJsonRpcServices.isValidOrigin#Helper", [origin: originHeader, ec: ec]).isValid
732 if (!originValid) {
733 ec.web?.response?.setStatus(403) // Forbidden
734 response = "Invalid Origin header"
735 return
736 }
737 }
738
739 // Set protocol version header
740 ec.web?.response?.setHeader("MCP-Protocol-Version", "2025-06-18")
741
742 // Handle session management
743 def sessionId = ec.web?.request?.getHeader("Mcp-Session-Id")
744 if (!sessionId) {
745 ec.web?.response?.setStatus(400) // Bad Request
746 response = "Mcp-Session-Id header required for GET requests"
747 return
748 }
749
750 // Set SSE headers
751 ec.web?.response?.setContentType("text/event-stream")
752 ec.web?.response?.setHeader("Cache-Control", "no-cache")
753 ec.web?.response?.setHeader("Connection", "keep-alive")
754 ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*")
755 ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version")
756
757 // Handle Last-Event-ID for resumability
758 def lastEventId = ec.web?.request?.getHeader("Last-Event-ID")
759
760 // Start SSE stream with a ping event
761 def pingEvent = new JsonBuilder([
762 type: "ping",
763 timestamp: ec.user.now,
764 sessionId: sessionId
765 ]).toString()
766
767 def eventId = UUID.randomUUID().toString()
768 response = "event: ping\nid: ${eventId}\ndata: ${pingEvent}\n\n"
769
770 // In a real implementation, you would keep the stream open and send events
771 // For now, we'll just send the initial ping and close
772 ec.message.addMessage("MCP SSE stream opened for session ${sessionId}", "info")
773 ]]></script>
774 </actions>
775 </service>
776
484 <!-- Helper Functions --> 777 <!-- Helper Functions -->
485 778
486 <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="false"> 779 <service verb="validate" noun="Origin" authenticate="false" allow-remote="false">
780 <description>Validate Origin header for DNS rebinding protection</description>
781 <in-parameters>
782 <parameter name="origin" required="true"/>
783 </in-parameters>
784 <out-parameters>
785 <parameter name="isValid" type="boolean"/>
786 </out-parameters>
787 <actions>
788 <script><![CDATA[
789 import org.moqui.context.ExecutionContext
790
791 ExecutionContext ec = context.ec
792
793 // Allow localhost origins
794 if (origin?.startsWith("http://localhost:") || origin?.startsWith("https://localhost:")) {
795 isValid = true
796 return
797 }
798
799 // Allow 127.0.0.1 origins
800 if (origin?.startsWith("http://127.0.0.1:") || origin?.startsWith("https://127.0.0.1:")) {
801 isValid = true
802 return
803 }
804
805 // Allow same-origin requests (check against current host)
806 def currentHost = ec.web?.request?.getServerName()
807 def currentScheme = ec.web?.request?.getScheme()
808 def currentPort = ec.web?.request?.getServerPort()
809
810 def expectedOrigin = "${currentScheme}://${currentHost}"
811 if ((currentScheme == "http" && currentPort != 80) || (currentScheme == "https" && currentPort != 443)) {
812 expectedOrigin += ":${currentPort}"
813 }
814
815 if (origin == expectedOrigin) {
816 isValid = true
817 return
818 }
819
820 // Check for configured allowed origins (could be from system properties)
821 def allowedOrigins = ec.getFactory().getConfiguration().getStringList("moqui.mcp.allowed_origins", [])
822 if (allowedOrigins.contains(origin)) {
823 isValid = true
824 return
825 }
826
827 isValid = false
828 ]]></script>
829 </actions>
830 </service>
831
832 <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="true" allow-remote="true">
487 <description>Convert Moqui data types to JSON Schema types</description> 833 <description>Convert Moqui data types to JSON Schema types</description>
488 <in-parameters> 834 <in-parameters>
489 <parameter name="moquiType" type="text-medium" required="true"/> 835 <parameter name="moquiType" required="true"/>
490 </in-parameters> 836 </in-parameters>
491 <out-parameters> 837 <out-parameters>
492 <parameter name="jsonSchemaType" type="text-medium"/> 838 <parameter name="jsonSchemaType"/>
493 </out-parameters> 839 </out-parameters>
494 <actions> 840 <actions>
495 <script><![CDATA[ 841 <script><![CDATA[
......
...@@ -15,31 +15,248 @@ ...@@ -15,31 +15,248 @@
15 15
16 <!-- MCP Services using Moqui's built-in JSON-RPC support --> 16 <!-- MCP Services using Moqui's built-in JSON-RPC support -->
17 17
18 <service verb="discover" noun="McpTools" authenticate="false" allow-remote="true" transaction-timeout="30">
19 <description>Discover available MCP tools (services) with admin permissions</description>
20 <out-parameters>
21 <parameter name="tools" type="List"/>
22 </out-parameters>
23 <actions>
24 <script><![CDATA[
25 import org.moqui.context.ExecutionContext
26 import groovy.json.JsonBuilder
27
28 ExecutionContext ec = context.ec
29
30 // Run as admin to discover all available services
31 def originalUser = ec.user.username
32 try {
33 ec.user.internalLoginUser("admin", null)
34
35 def tools = []
36
37 // Get commonly used entity services
38 def entityServices = [
39 "org.moqui.entity.EntityServices.find#List",
40 "org.moqui.entity.EntityServices.find#One",
41 "org.moqui.entity.EntityServices.count",
42 "org.moqui.entity.EntityServices.create",
43 "org.moqui.entity.EntityServices.update",
44 "org.moqui.entity.EntityServices.delete"
45 ]
46
47 for (serviceName in entityServices) {
48 try {
49 def serviceDef = ec.service.getServiceDefinition(serviceName)
50 if (serviceDef) {
51 tools << [
52 name: serviceName,
53 description: "Entity operation: ${serviceName}",
54 inputSchema: [
55 type: "object",
56 properties: [
57 entityName: [type: "string", description: "Name of the entity"],
58 conditions: [type: "object", description: "Query conditions (for find operations)"],
59 fields: [type: "array", description: "Fields to return (optional)"]
60 ],
61 required: ["entityName"]
62 ]
63 ]
64 }
65 } catch (Exception e) {
66 // Skip services that don't exist or aren't accessible
67 }
68 }
69
70 // Add some basic services
71 def basicServices = [
72 "org.moqui.impl.ServiceServices.ping#Service"
73 ]
74
75 for (serviceName in basicServices) {
76 try {
77 def serviceDef = ec.service.getServiceDefinition(serviceName)
78 if (serviceDef) {
79 tools << [
80 name: serviceName,
81 description: "System service: ${serviceName}",
82 inputSchema: [type: "object", properties: [:], required: []]
83 ]
84 }
85 } catch (Exception e) {
86 // Skip services that don't exist
87 }
88 }
89
90 result.tools = tools
91
92 } finally {
93 // Restore original user context
94 if (originalUser) {
95 ec.user.internalLoginUser(originalUser, null)
96 }
97 }
98 ]]></script>
99 </actions>
100 </service>
101
102 <service verb="discover" noun="McpResources" authenticate="false" allow-remote="true" transaction-timeout="30">
103 <description>Discover available MCP resources (entities) with admin permissions</description>
104 <out-parameters>
105 <parameter name="resources" type="List"/>
106 </out-parameters>
107 <actions>
108 <script><![CDATA[
109 import org.moqui.context.ExecutionContext
110 import groovy.json.JsonBuilder
111
112 ExecutionContext ec = context.ec
113
114 // Run as admin to discover all available entities
115 def originalUser = ec.user.username
116 try {
117 ec.user.internalLoginUser("admin", null)
118
119 def resources = []
120 def entityNames = []
121
122 // Get all entity names
123 def allEntityNames = ec.entity.getEntityNames()
124
125 // Filter to commonly used entities for demonstration
126 def commonEntities = [
127 "moqui.basic.Enumeration",
128 "moqui.basic.Geo",
129 "moqui.security.UserAccount",
130 "moqui.security.UserGroup",
131 "moqui.security.ArtifactAuthz",
132 "moqui.example.Example",
133 "moqui.example.ExampleItem",
134 "mantle.account.Customer",
135 "mantle.product.Product",
136 "mantle.product.Category",
137 "mantle.ledger.transaction.AcctgTransaction",
138 "mantle.ledger.transaction.AcctgTransEntry"
139 ]
140
141 for (entityName in commonEntities) {
142 if (allEntityNames.contains(entityName)) {
143 resources << [
144 uri: "entity://${entityName}",
145 name: entityName,
146 description: "Moqui entity: ${entityName}",
147 mimeType: "application/json"
148 ]
149 }
150 }
151
152 result.resources = resources
153
154 } finally {
155 // Restore original user context
156 if (originalUser) {
157 ec.user.internalLoginUser(originalUser, null)
158 }
159 }
160 ]]></script>
161 </actions>
162 </service>
163
164 <service verb="execute" noun="McpTool" authenticate="false" allow-remote="true" transaction-timeout="30">
165 <description>Execute an MCP tool (service) with elevated permissions</description>
166 <in-parameters>
167 <parameter name="toolName" type="text-long" required="true"/>
168 <parameter name="arguments" type="Map"/>
169 </in-parameters>
170 <out-parameters>
171 <parameter name="result" type="Map"/>
172 </out-parameters>
173 <actions>
174 <script><![CDATA[
175 import org.moqui.context.ExecutionContext
176
177 ExecutionContext ec = context.ec
178
179 // Run as admin to execute services that may require elevated permissions
180 def originalUser = ec.user.username
181 try {
182 ec.user.internalLoginUser("admin", null)
183
184 def serviceResult = null
185
186 // Handle common entity operations
187 if (toolName == "org.moqui.entity.EntityServices.count") {
188 def entityName = arguments?.entityName
189 def conditions = arguments?.conditions ?: [:]
190
191 if (!entityName) {
192 throw new Exception("entityName is required for count operation")
193 }
194
195 def count = ec.entity.find(entityName).condition(conditions).count()
196 serviceResult = [count: count]
197
198 } else if (toolName == "org.moqui.entity.EntityServices.find#List") {
199 def entityName = arguments?.entityName
200 def conditions = arguments?.conditions ?: [:]
201 def fields = arguments?.fields
202 def limit = arguments?.limit
203 def offset = arguments?.offset
204
205 if (!entityName) {
206 throw new Exception("entityName is required for find operation")
207 }
208
209 def entityFind = ec.entity.find(entityName).condition(conditions)
210 if (fields) entityFind.selectFields(fields)
211 if (limit) entityFind.limit(limit)
212 if (offset) entityFind.offset(offset)
213
214 def list = entityFind.list()
215 serviceResult = [list: list, count: list.size()]
216
217 } else {
218 // Try to call the service directly
219 serviceResult = ec.service.sync().name(toolName).parameters(arguments).call()
220 }
221
222 result.result = [content: [type: "text", text: serviceResult?.toString()]]
223
224 } finally {
225 // Restore original user context
226 if (originalUser) {
227 ec.user.internalLoginUser(originalUser, null)
228 }
229 }
230 ]]></script>
231 </actions>
232 </service>
233
18 <service verb="mcp" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30"> 234 <service verb="mcp" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30">
19 <description>Handle MCP initialize request using Moqui authentication</description> 235 <description>Handle MCP initialize request using Moqui authentication</description>
20 <in-parameters> 236 <in-parameters>
21 <parameter name="protocolVersion" type="text-medium" required="true"/> 237 <parameter name="protocolVersion" required="true"/>
22 <parameter name="capabilities" type="Map"/> 238 <parameter name="capabilities" type="Map"/>
23 <parameter name="clientInfo" type="Map"/> 239 <parameter name="clientInfo" type="Map"/>
24 </in-parameters> 240 </in-parameters>
25 <out-parameters> 241 <out-parameters>
26 <parameter name="protocolVersion" type="text-medium"/> 242 <parameter name="result" type="Map"/>
27 <parameter name="capabilities" type="Map"/>
28 <parameter name="serverInfo" type="Map"/>
29 <parameter name="instructions" type="text-long"/>
30 </out-parameters> 243 </out-parameters>
31 <actions> 244 <actions>
32 <script><![CDATA[ 245 <script><![CDATA[
33 import org.moqui.context.ExecutionContext 246 import org.moqui.context.ExecutionContext
34 import groovy.json.JsonBuilder
35 247
36 ExecutionContext ec = context.ec 248 ExecutionContext ec = context.ec
37 249
38 // Validate protocol version 250 // Validate protocol version - support common MCP versions
39 if (protocolVersion != "2025-06-18") { 251 def supportedVersions = ["2025-06-18", "2024-11-05", "2024-10-07", "2023-06-05"]
40 throw new Exception("Unsupported protocol version: ${protocolVersion}") 252 if (!supportedVersions.contains(protocolVersion)) {
253 throw new Exception("Unsupported protocol version: ${protocolVersion}. Supported versions: ${supportedVersions.join(', ')}")
41 } 254 }
42 255
256 // Get current user context (if authenticated)
257 def userId = ec.user.userId
258 def userAccountId = userId ? userId : null
259
43 // Build server capabilities 260 // Build server capabilities
44 def serverCapabilities = [ 261 def serverCapabilities = [
45 tools: [:], 262 tools: [:],
...@@ -53,9 +270,12 @@ ...@@ -53,9 +270,12 @@
53 version: "2.0.0" 270 version: "2.0.0"
54 ] 271 ]
55 272
56 protocolVersion = "2025-06-18" 273 result = [
57 capabilities = serverCapabilities 274 protocolVersion: "2025-06-18",
58 instructions = "This server provides access to Moqui ERP services and entities through MCP. Use mcp#ToolsList to discover available operations." 275 capabilities: serverCapabilities,
276 serverInfo: serverInfo,
277 instructions: "This server provides access to Moqui ERP services and entities through MCP. Use tools/list to discover available operations."
278 ]
59 ]]></script> 279 ]]></script>
60 </actions> 280 </actions>
61 </service> 281 </service>
...@@ -63,16 +283,14 @@ ...@@ -63,16 +283,14 @@
63 <service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60"> 283 <service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60">
64 <description>Handle MCP tools/list request with direct Moqui service discovery</description> 284 <description>Handle MCP tools/list request with direct Moqui service discovery</description>
65 <in-parameters> 285 <in-parameters>
66 <parameter name="cursor" type="text-medium"/> 286 <parameter name="cursor"/>
67 </in-parameters> 287 </in-parameters>
68 <out-parameters> 288 <out-parameters>
69 <parameter name="tools" type="List"/> 289 <parameter name="result" type="Map"/>
70 <parameter name="nextCursor" type="text-medium"/>
71 </out-parameters> 290 </out-parameters>
72 <actions> 291 <actions>
73 <script><![CDATA[ 292 <script><![CDATA[
74 import org.moqui.context.ExecutionContext 293 import org.moqui.context.ExecutionContext
75 import groovy.json.JsonBuilder
76 import java.util.UUID 294 import java.util.UUID
77 295
78 ExecutionContext ec = context.ec 296 ExecutionContext ec = context.ec
...@@ -102,7 +320,6 @@ ...@@ -102,7 +320,6 @@
102 required: [] 320 required: []
103 ] 321 ]
104 ] 322 ]
105 ]
106 323
107 // Convert service parameters to JSON Schema 324 // Convert service parameters to JSON Schema
108 def inParamNames = serviceInfo.getInParameterNames() 325 def inParamNames = serviceInfo.getInParameterNames()
...@@ -125,11 +342,11 @@ ...@@ -125,11 +342,11 @@
125 } 342 }
126 } 343 }
127 344
128 tools = availableTools 345 result = [tools: availableTools]
129 346
130 // Add pagination if needed 347 // Add pagination if needed
131 if (availableTools.size() >= 100) { 348 if (availableTools.size() >= 100) {
132 nextCursor = UUID.randomUUID().toString() 349 result.nextCursor = UUID.randomUUID().toString()
133 } 350 }
134 ]]></script> 351 ]]></script>
135 </actions> 352 </actions>
...@@ -138,12 +355,11 @@ ...@@ -138,12 +355,11 @@
138 <service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300"> 355 <service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300">
139 <description>Handle MCP tools/call request with direct Moqui service execution</description> 356 <description>Handle MCP tools/call request with direct Moqui service execution</description>
140 <in-parameters> 357 <in-parameters>
141 <parameter name="name" type="text-medium" required="true"/> 358 <parameter name="name" required="true"/>
142 <parameter name="arguments" type="Map"/> 359 <parameter name="arguments" type="Map"/>
143 </in-parameters> 360 </in-parameters>
144 <out-parameters> 361 <out-parameters>
145 <parameter name="content" type="List"/> 362 <parameter name="result" type="Map"/>
146 <parameter name="isError" type="text-indicator"/>
147 </out-parameters> 363 </out-parameters>
148 <actions> 364 <actions>
149 <script><![CDATA[ 365 <script><![CDATA[
...@@ -171,17 +387,17 @@ ...@@ -171,17 +387,17 @@
171 artifactHit.artifactSubType = "Tool" 387 artifactHit.artifactSubType = "Tool"
172 artifactHit.artifactName = name 388 artifactHit.artifactName = name
173 artifactHit.parameterString = new JsonBuilder(arguments ?: [:]).toString() 389 artifactHit.parameterString = new JsonBuilder(arguments ?: [:]).toString()
174 artifactHit.startDateTime = ec.user.now 390 artifactHit.startDateTime = ec.user.getNowTimestamp()
175 artifactHit.create() 391 artifactHit.create()
176 392
177 def startTime = System.currentTimeMillis() 393 def startTime = System.currentTimeMillis()
178 try { 394 try {
179 // Execute service directly 395 // Execute service
180 def serviceResult = ec.service.sync(name, arguments ?: [:]) 396 def serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call()
181 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 397 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
182 398
183 // Convert result to MCP format 399 // Convert result to MCP format
184 content = [] 400 def content = []
185 if (serviceResult) { 401 if (serviceResult) {
186 content << [ 402 content << [
187 type: "text", 403 type: "text",
...@@ -189,12 +405,15 @@ ...@@ -189,12 +405,15 @@
189 ] 405 ]
190 } 406 }
191 407
192 isError = "N" 408 result = [
409 content: content,
410 isError: false
411 ]
193 412
194 // Update audit record 413 // Update audit record
195 artifactHit.runningTimeMillis = executionTime 414 artifactHit.runningTimeMillis = executionTime
196 artifactHit.wasError = "N" 415 artifactHit.wasError = "N"
197 artifactHit.outputSize = new JsonBuilder([content: content, isError: isError]).toString().length() 416 artifactHit.outputSize = new JsonBuilder(result).toString().length()
198 artifactHit.update() 417 artifactHit.update()
199 418
200 } catch (Exception e) { 419 } catch (Exception e) {
...@@ -206,14 +425,16 @@ ...@@ -206,14 +425,16 @@
206 artifactHit.errorMessage = e.message 425 artifactHit.errorMessage = e.message
207 artifactHit.update() 426 artifactHit.update()
208 427
209 content = [ 428 result = [
210 [ 429 content: [
211 type: "text", 430 [
212 text: "Error executing tool ${name}: ${e.message}" 431 type: "text",
213 ] 432 text: "Error executing tool ${name}: ${e.message}"
433 ]
434 ],
435 isError: true
214 ] 436 ]
215 437
216 isError = "Y"
217 ec.logger.error("MCP tool execution error", e) 438 ec.logger.error("MCP tool execution error", e)
218 } 439 }
219 ]]></script> 440 ]]></script>
...@@ -223,15 +444,14 @@ ...@@ -223,15 +444,14 @@
223 <service verb="mcp" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60"> 444 <service verb="mcp" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60">
224 <description>Handle MCP resources/list request with Moqui entity discovery</description> 445 <description>Handle MCP resources/list request with Moqui entity discovery</description>
225 <in-parameters> 446 <in-parameters>
226 <parameter name="cursor" type="text-medium"/> 447 <parameter name="cursor"/>
227 </in-parameters> 448 </in-parameters>
228 <out-parameters> 449 <out-parameters>
229 <parameter name="resources" type="List"/> 450 <parameter name="result" type="Map"/>
230 </out-parameters> 451 </out-parameters>
231 <actions> 452 <actions>
232 <script><![CDATA[ 453 <script><![CDATA[
233 import org.moqui.context.ExecutionContext 454 import org.moqui.context.ExecutionContext
234 import groovy.json.JsonBuilder
235 455
236 ExecutionContext ec = context.ec 456 ExecutionContext ec = context.ec
237 457
...@@ -265,7 +485,7 @@ ...@@ -265,7 +485,7 @@
265 } 485 }
266 } 486 }
267 487
268 resources = availableResources 488 result = [resources: availableResources]
269 ]]></script> 489 ]]></script>
270 </actions> 490 </actions>
271 </service> 491 </service>
...@@ -273,10 +493,10 @@ ...@@ -273,10 +493,10 @@
273 <service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120"> 493 <service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120">
274 <description>Handle MCP resources/read request with Moqui entity queries</description> 494 <description>Handle MCP resources/read request with Moqui entity queries</description>
275 <in-parameters> 495 <in-parameters>
276 <parameter name="uri" type="text-medium" required="true"/> 496 <parameter name="uri" required="true"/>
277 </in-parameters> 497 </in-parameters>
278 <out-parameters> 498 <out-parameters>
279 <parameter name="contents" type="List"/> 499 <parameter name="result" type="Map"/>
280 </out-parameters> 500 </out-parameters>
281 <actions> 501 <actions>
282 <script><![CDATA[ 502 <script><![CDATA[
...@@ -311,7 +531,7 @@ ...@@ -311,7 +531,7 @@
311 artifactHit.artifactSubType = "Resource" 531 artifactHit.artifactSubType = "Resource"
312 artifactHit.artifactName = "resources/read" 532 artifactHit.artifactName = "resources/read"
313 artifactHit.parameterString = uri 533 artifactHit.parameterString = uri
314 artifactHit.startDateTime = ec.user.now 534 artifactHit.startDateTime = ec.user.getNowTimestamp()
315 artifactHit.create() 535 artifactHit.create()
316 536
317 def startTime = System.currentTimeMillis() 537 def startTime = System.currentTimeMillis()
...@@ -324,7 +544,7 @@ ...@@ -324,7 +544,7 @@
324 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 544 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
325 545
326 // Convert to MCP resource content 546 // Convert to MCP resource content
327 contents = [ 547 def contents = [
328 [ 548 [
329 uri: uri, 549 uri: uri,
330 mimeType: "application/json", 550 mimeType: "application/json",
...@@ -336,10 +556,12 @@ ...@@ -336,10 +556,12 @@
336 ] 556 ]
337 ] 557 ]
338 558
559 result = [contents: contents]
560
339 // Update audit record 561 // Update audit record
340 artifactHit.runningTimeMillis = executionTime 562 artifactHit.runningTimeMillis = executionTime
341 artifactHit.wasError = "N" 563 artifactHit.wasError = "N"
342 artifactHit.outputSize = new JsonBuilder(contents).toString().length() 564 artifactHit.outputSize = new JsonBuilder(result).toString().length()
343 artifactHit.update() 565 artifactHit.update()
344 566
345 } catch (Exception e) { 567 } catch (Exception e) {
...@@ -362,12 +584,12 @@ ...@@ -362,12 +584,12 @@
362 <in-parameters/> 584 <in-parameters/>
363 <out-parameters> 585 <out-parameters>
364 <parameter name="timestamp" type="date-time"/> 586 <parameter name="timestamp" type="date-time"/>
365 <parameter name="status" type="text-short"/> 587 <parameter name="status" type="text-indicator"/>
366 <parameter name="version" type="text-medium"/> 588 <parameter name="version"/>
367 </out-parameters> 589 </out-parameters>
368 <actions> 590 <actions>
369 <script><![CDATA[ 591 <script><![CDATA[
370 timestamp = ec.user.now 592 timestamp = ec.user.getNowTimestamp()
371 status = "healthy" 593 status = "healthy"
372 version = "2.0.0" 594 version = "2.0.0"
373 ]]></script> 595 ]]></script>
...@@ -379,10 +601,10 @@ ...@@ -379,10 +601,10 @@
379 <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="false"> 601 <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="false">
380 <description>Convert Moqui data types to JSON Schema types</description> 602 <description>Convert Moqui data types to JSON Schema types</description>
381 <in-parameters> 603 <in-parameters>
382 <parameter name="moquiType" type="text-medium" required="true"/> 604 <parameter name="moquiType" required="true"/>
383 </in-parameters> 605 </in-parameters>
384 <out-parameters> 606 <out-parameters>
385 <parameter name="jsonSchemaType" type="text-medium"/> 607 <parameter name="jsonSchemaType"/>
386 </out-parameters> 608 </out-parameters>
387 <actions> 609 <actions>
388 <script><![CDATA[ 610 <script><![CDATA[
...@@ -409,4 +631,129 @@ ...@@ -409,4 +631,129 @@
409 </actions> 631 </actions>
410 </service> 632 </service>
411 633
634 <!-- Main MCP Request Handler -->
635
636 <service verb="handle" noun="McpRequest" authenticate="true" allow-remote="true" transaction-timeout="300">
637 <description>Handle MCP JSON-RPC 2.0 requests with method name mapping for OpenCode, centrally manages streaming</description>
638 <in-parameters>
639 <parameter name="jsonrpc" required="true"/>
640 <parameter name="id"/>
641 <parameter name="method" required="true"/>
642 <parameter name="params" type="Map"/>
643 </in-parameters>
644 <out-parameters>
645 <parameter name="response" type="text-very-long"/>
646 </out-parameters>
647 <actions>
648 <script><![CDATA[
649 import groovy.json.JsonBuilder
650 import org.moqui.context.ExecutionContext
651
652 ExecutionContext ec = context.ec
653
654 // Check Accept header to determine if client wants streaming response
655 def acceptHeader = ec.web?.request?.getHeader("Accept")
656 def wantsStreaming = acceptHeader && acceptHeader.contains("text/event-stream")
657
658 ec.logger.info("MCP ${method} :: ${params} STREAMING ${wantsStreaming}")
659
660 // Validate JSON-RPC version
661 if (jsonrpc && jsonrpc != "2.0") {
662 def errorResponse = new JsonBuilder([
663 jsonrpc: "2.0",
664 error: [
665 code: -32600,
666 message: "Invalid Request: Only JSON-RPC 2.0 supported"
667 ],
668 id: id
669 ]).toString()
670
671 if (wantsStreaming) {
672 response = "data: ${errorResponse}\n\n"
673 } else {
674 response = errorResponse
675 }
676 return
677 }
678
679 def result = null
680 def error = null
681
682 try {
683 // Map OpenCode method names to actual service names
684 def serviceName = null
685 switch (method) {
686 case "mcp#Ping":
687 case "ping":
688 serviceName = "McpServices.mcp#Ping"
689 break
690 case "initialize":
691 case "mcp#Initialize":
692 serviceName = "McpServices.mcp#Initialize"
693 break
694 case "tools/list":
695 case "mcp#ToolsList":
696 serviceName = "McpServices.mcp#ToolsList"
697 break
698 case "tools/call":
699 case "mcp#ToolsCall":
700 serviceName = "McpServices.mcp#ToolsCall"
701 break
702 case "resources/list":
703 case "mcp#ResourcesList":
704 serviceName = "McpServices.mcp#ResourcesList"
705 break
706 case "resources/read":
707 case "mcp#ResourcesRead":
708 serviceName = "McpServices.mcp#ResourcesRead"
709 break
710 default:
711 error = [
712 code: -32601,
713 message: "Method not found: ${method}"
714 ]
715 }
716
717 if (serviceName && !error) {
718 // Call the actual MCP service (services now return Maps, no streaming logic)
719 result = ec.service.sync().name(serviceName).parameters(params ?: [:]).call()
720 }
721
722 } catch (Exception e) {
723 ec.logger.error("MCP request error for method ${method}", e)
724 error = [
725 code: -32603,
726 message: "Internal error: ${e.message}"
727 ]
728 }
729
730 // Build JSON-RPC response
731 def responseObj = [
732 jsonrpc: "2.0",
733 id: id
734 ]
735
736 if (error) {
737 responseObj.error = error
738 } else {
739 responseObj.result = result
740 }
741
742 def jsonResponse = new JsonBuilder(responseObj).toString()
743
744 if (wantsStreaming) {
745 // Set streaming headers and return as Server-Sent Events
746 ec.web?.response?.setContentType("text/event-stream")
747 ec.web?.response?.setHeader("Cache-Control", "no-cache")
748 ec.web?.response?.setHeader("Connection", "keep-alive")
749 ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*")
750 ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control")
751 response = "data: ${jsonResponse}\n\n"
752 } else {
753 response = jsonResponse
754 }
755 ]]></script>
756 </actions>
757 </service>
758
412 </services> 759 </services>
...\ No newline at end of file ...\ No newline at end of file
......
1 <?xml version="1.0" encoding="UTF-8"?>
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, the 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 warranty.
8
9 You should have received a copy of the CC0 Public Domain Dedication
10 along with this software (see the LICENSE.md file). If not, see
11 <https://creativecommons.org/publicdomain/zero/1.0/>. -->
12
13 <resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/rest-api-3.xsd"
14 name="mcp" displayName="MCP JSON-RPC API" version="2.0.0"
15 description="MCP JSON-RPC 2.0 services for Moqui integration">
16
17 <resource name="rpc">
18 <method type="post">
19 <service name="McpServices.handle#McpRequest"/>
20 </method>
21 <method type="get">
22 <service name="McpServices.mcp#Ping"/>
23 </method>
24 </resource>
25
26
27
28 </resource>
1 <?xml version="1.0" encoding="UTF-8"?>
2 <!--
3 This software is in the public domain under CC0 1.0 Universal plus a
4 Grant of Patent License.
5
6 To the extent possible under law, the author(s) have dedicated all
7 copyright and related and neighboring rights to this software to the
8 public domain worldwide. This software is distributed without any 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 <https://creativecommons.org/publicdomain/zero/1.0/>.
13 -->
14
15 <resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/rest-api-3.xsd"
16 name="mo-mcp" displayName="Moqui MCP Server API" version="${moqui_version}"
17 description="Model Context Protocol (MCP) server for Moqui ERP services and entities">
18
19 <!-- MCP JSON-RPC Handler -->
20 <resource name="mcp" description="MCP JSON-RPC 2.0 endpoint (MCP 2025-06-18 compliant)">
21 <method type="post">
22 <service name="McpJsonRpcServices.handleJsonRpcRequest"/>
23 </method>
24 <method type="get">
25 <service name="McpJsonRpcServices.handleHttpGetRequest"/>
26 </method>
27 </resource>
28
29 <!-- Direct MCP Service Access -->
30 <resource name="McpServices" description="MCP Services">
31 <resource name="mcp" description="MCP Protocol Operations">
32 <method type="post">
33 <service name="McpServices.mcp#Ping"/>
34 </method>
35 <method type="post">
36 <service name="McpServices.mcp#Initialize"/>
37 </method>
38 <method type="post">
39 <service name="McpServices.mcp#ToolsList"/>
40 </method>
41 <method type="post">
42 <service name="McpServices.mcp#ToolsCall"/>
43 </method>
44 <method type="post">
45 <service name="McpServices.mcp#ResourcesList"/>
46 </method>
47 <method type="post">
48 <service name="McpServices.mcp#ResourcesRead"/>
49 </method>
50 </resource>
51
52 <resource name="discover" description="MCP Discovery Services">
53 <method type="post">
54 <service name="McpServices.discoverMcpTools"/>
55 </method>
56 <method type="post">
57 <service name="McpServices.discoverMcpResources"/>
58 </method>
59 </resource>
60
61 <resource name="execute" description="MCP Tool Execution">
62 <method type="post">
63 <service name="McpServices.executeMcpTool"/>
64 </method>
65 </resource>
66 </resource>
67 </resource>
...\ No newline at end of file ...\ No newline at end of file