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
Showing
7 changed files
with
11 additions
and
231 deletions
This diff is collapsed.
Click to expand it.
| ... | @@ -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"/> | ... | ... |
screen-hold/webapp.xml
deleted
100644 → 0
| 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 |
screen/webroot/mcp.xml
0 → 100644
This diff is collapsed.
Click to expand it.
This diff is collapsed.
Click to expand it.
| ... | @@ -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 | <resource name="rpc"> | 17 | <!-- NOTE: Main MCP functionality moved to screen/webapp.xml for unified JSON-RPC and SSE handling --> |
| 18 | <method type="post" content-type="application/json"> | 18 | <!-- Keeping only basic GET endpoints for health checks and debugging --> |
| 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 | 19 | ||
| 20 | <resource name="rpc"> | ||
| 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 | 28 | ||
| 37 | |||
| 38 | |||
| 39 | </resource> | 29 | </resource> | ... | ... |
-
Please register or sign in to post a comment