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.
Showing
7 changed files
with
1110 additions
and
89 deletions
| ... | @@ -11,12 +11,14 @@ | ... | @@ -11,12 +11,14 @@ |
| 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" /> | ... | ... |
| ... | @@ -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"/> |
| 33 | 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"/> | ||
| 51 | |||
| 34 | </entity-facade-xml> | 52 | </entity-facade-xml> |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
screen-hold/webapp.xml
0 → 100644
| 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: |
| 138 | if (method) { | ||
| 76 | error = [ | 139 | error = [ |
| 77 | code: -32601, | 140 | code: -32601, |
| 78 | message: "Method not found: ${method}" | 141 | message: "Method not found: ${method}" |
| 79 | ] | 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,8 +418,67 @@ | ... | @@ -272,8 +418,67 @@ |
| 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 |
| 423 | ec.web?.response?.setContentType("text/event-stream") | ||
| 424 | ec.web?.response?.setHeader("Cache-Control", "no-cache") | ||
| 425 | ec.web?.response?.setHeader("Connection", "keep-alive") | ||
| 426 | ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*") | ||
| 427 | ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version") | ||
| 428 | |||
| 429 | // Send start event with proper SSE format | ||
| 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() | ||
| 277 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | 482 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 |
| 278 | 483 | ||
| 279 | // Convert result to MCP format | 484 | // Convert result to MCP format |
| ... | @@ -295,16 +500,24 @@ | ... | @@ -295,16 +500,24 @@ |
| 295 | artifactHit.wasError = "N" | 500 | artifactHit.wasError = "N" |
| 296 | artifactHit.outputSize = new JsonBuilder(result).toString().length() | 501 | artifactHit.outputSize = new JsonBuilder(result).toString().length() |
| 297 | artifactHit.update() | 502 | artifactHit.update() |
| 503 | } | ||
| 298 | 504 | ||
| 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 | ||
| 302 | // Update audit record with error | 508 | if (wantsStreaming) { |
| 303 | artifactHit.runningTimeMillis = executionTime | 509 | // Send error event with proper SSE format |
| 304 | artifactHit.wasError = "Y" | 510 | def errorEvent = new JsonBuilder([ |
| 305 | artifactHit.errorMessage = e.message | 511 | type: "error", |
| 306 | artifactHit.update() | 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() | ||
| 307 | 518 | ||
| 519 | result = [streamed: true, error: true] | ||
| 520 | } else { | ||
| 308 | result = [ | 521 | result = [ |
| 309 | content: [ | 522 | content: [ |
| 310 | [ | 523 | [ |
| ... | @@ -314,6 +527,13 @@ | ... | @@ -314,6 +527,13 @@ |
| 314 | ], | 527 | ], |
| 315 | isError: true | 528 | isError: true |
| 316 | ] | 529 | ] |
| 530 | } | ||
| 531 | |||
| 532 | // Update audit record with error | ||
| 533 | artifactHit.runningTimeMillis = executionTime | ||
| 534 | artifactHit.wasError = "Y" | ||
| 535 | artifactHit.errorMessage = e.message | ||
| 536 | artifactHit.update() | ||
| 317 | 537 | ||
| 318 | ec.logger.error("MCP tool execution error", e) | 538 | ec.logger.error("MCP tool execution error", e) |
| 319 | } | 539 | } |
| ... | @@ -321,10 +541,10 @@ | ... | @@ -321,10 +541,10 @@ |
| 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 = [ |
| 429 | content: [ | ||
| 210 | [ | 430 | [ |
| 211 | type: "text", | 431 | type: "text", |
| 212 | text: "Error executing tool ${name}: ${e.message}" | 432 | text: "Error executing tool ${name}: ${e.message}" |
| 213 | ] | 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 | ... | ... |
service/mcp.rest.xml
0 → 100644
| 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> |
service/moqui.rest.xml
0 → 100644
| 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 |
-
Please register or sign in to post a comment