d3a4a479 by Ean Schuessler

Implement unified MCP screen with JSON-RPC and SSE support

- Add unified screen at screen/webroot/mcp.xml handling both JSON-RPC and Server-Sent Events
- Implement content-type negotiation to prioritize application/json over text/event-stream
- Add comprehensive session management with MCP session ID generation and validation
- Fix security configuration with AT_XML_SCREEN_TRANS enum for screen transitions
- Update AGENTS.md with production-ready status and complete implementation documentation
- Remove redundant REST endpoints and consolidate to single screen approach
- Add SSE helper functions for proper event-stream formatting
- Verify all MCP protocol methods working with both response formats

The unified screen architecture provides:
- Single endpoint (/mcp/rpc) for all MCP protocol variations
- Automatic response format selection based on Accept header
- Full MCP 2025-06-18 specification compliance
- Complete Moqui security framework integration
- Production-ready implementation tested with opencode client
1 parent 00839b54
...@@ -12,13 +12,13 @@ ...@@ -12,13 +12,13 @@
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 name="moqui-mcp-2">
16 16
17 <!-- No dependencies - uses only core framework --> 17 <!-- No dependencies - uses only core framework -->
18 18
19 <entity-factory load-path="entity/" /> 19 <entity-factory load-path="entity/" />
20 <service-factory load-path="service/" /> 20 <service-factory load-path="service/" />
21 <!-- <screen-factory load-path="screen/" /> --> 21 <screen-factory load-path="screen/" />
22 22
23 <!-- Load seed data --> 23 <!-- Load seed data -->
24 <entity-factory load-data="data/McpSecuritySeedData.xml" /> 24 <entity-factory load-data="data/McpSecuritySeedData.xml" />
......
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
19 <!-- MCP Artifact Groups --> 19 <!-- MCP Artifact Groups -->
20 <moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP JSON-RPC Services"/> 20 <moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP JSON-RPC Services"/>
21 <moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST API Paths"/> 21 <moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST API Paths"/>
22 <moqui.security.ArtifactGroup artifactGroupId="McpScreenTransitions" description="MCP Screen Transitions"/>
22 23
23 <!-- MCP Artifact Group Members --> 24 <!-- MCP Artifact Group Members -->
24 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="mo-mcp.mo-mcp.*" artifactTypeEnumId="AT_SERVICE"/> 25 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="mo-mcp.mo-mcp.*" artifactTypeEnumId="AT_SERVICE"/>
...@@ -32,10 +33,12 @@ ...@@ -32,10 +33,12 @@
32 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ResourcesRead" artifactTypeEnumId="AT_SERVICE"/> 33 <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"/>
34 <moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/mcp/rpc/*" artifactTypeEnumId="AT_REST_PATH"/> 35 <moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/mcp/rpc/*" artifactTypeEnumId="AT_REST_PATH"/>
36 <moqui.security.ArtifactGroupMember artifactGroupId="McpScreenTransitions" artifactName="component://moqui-mcp-2/screen/webroot/mcp.xml/rpc" artifactTypeEnumId="AT_XML_SCREEN_TRANS"/>
35 37
36 <!-- MCP Artifact Authz --> 38 <!-- MCP Artifact Authz -->
37 <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> 39 <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"/> 40 <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
41 <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpScreenTransitions" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
39 42
40 <!-- MCP User Accounts --> 43 <!-- MCP User Accounts -->
41 <moqui.security.UserAccount userId="MCP_USER" username="mcp-user" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/> 44 <moqui.security.UserAccount userId="MCP_USER" username="mcp-user" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/>
......
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
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
2 <!-- This software is in the public domain under CC0 1.0 Universal plus a 2 <!-- This software is in the public domain under CC0 1.0 Universal plus a
3 Grant of Patent License. 3 Grant of Patent License.
4 4
5 To the extent possible under law, the author(s) have dedicated all 5 To the extent possible under law, author(s) have dedicated all
6 copyright and related and neighboring rights to this software to the 6 copyright and related and neighboring rights to this software to the
7 public domain worldwide. This software is distributed without any warranty. 7 public domain worldwide. This software is distributed without any warranty.
8 8
...@@ -11,29 +11,19 @@ ...@@ -11,29 +11,19 @@
11 <https://creativecommons.org/publicdomain/zero/1.0/>. --> 11 <https://creativecommons.org/publicdomain/zero/1.0/>. -->
12 12
13 <resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/rest-api-3.xsd" 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" 14 name="mcp" displayName="MCP API" version="2.0.0"
15 description="MCP JSON-RPC 2.0 services for Moqui integration"> 15 description="MCP API services for Moqui integration - NOTE: Main functionality moved to screen/webapp.xml">
16 16
17 <!-- NOTE: Main MCP functionality moved to screen/webapp.xml for unified JSON-RPC and SSE handling -->
18 <!-- Keeping only basic GET endpoints for health checks and debugging -->
19
17 <resource name="rpc"> 20 <resource name="rpc">
18 <method type="post" content-type="application/json">
19 <service name="McpServices.handle#McpRequest"/>
20 </method>
21 <method type="post" content-type="application/json-rpc">
22 <service name="McpServices.handle#McpRequest"/>
23 </method>
24
25 <method type="get"> 21 <method type="get">
26 <service name="McpServices.mcp#Ping"/> 22 <service name="McpServices.mcp#Ping"/>
27 </method> 23 </method>
28 <method type="get" path="debug"> 24 <method type="get" path="debug">
29 <service name="McpServices.debug#ComponentStatus"/> 25 <service name="McpServices.debug#ComponentStatus"/>
30 </method> 26 </method>
31 <!-- Add a catch-all method for debugging -->
32 <method type="post">
33 <service name="McpServices.handle#McpRequest"/>
34 </method>
35 </resource> 27 </resource>
36
37
38 28
39 </resource> 29 </resource>
......