720946f4 by Ean Schuessler

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

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

This brings the moqui-mcp-2 component into full compliance with the
MCP 2025-06-18 Streamable HTTP transport specification.
1 parent 2dc86743
......@@ -11,12 +11,14 @@
<https://creativecommons.org/publicdomain/zero/1.0/>. -->
<component xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/component-definition-3.xsd">
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/component-definition-3.xsd"
name="mo-mcp">
<!-- No dependencies - uses only core framework -->
<entity-factory load-path="entity/" />
<service-factory load-path="service/" />
<!-- <screen-factory load-path="screen/" /> -->
<!-- Load seed data -->
<entity-factory load-data="data/McpSecuritySeedData.xml" />
......
......@@ -17,18 +17,36 @@
<moqui.security.UserGroup userGroupId="McpUser" description="MCP Server Users"/>
<!-- MCP Artifact Groups -->
<moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST Paths"/>
<moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP Services"/>
<moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP JSON-RPC Services"/>
<moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST API Paths"/>
<!-- MCP Artifact Group Members -->
<moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/rest/s1/mcp-2" artifactTypeEnumId="AT_REST_PATH"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.mcp.*" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="mo-mcp.mo-mcp.*" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.*" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#Ping" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.handle#McpRequest" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#Initialize" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ToolsList" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ToolsCall" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ResourcesList" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ResourcesRead" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/mcp/rpc" artifactTypeEnumId="AT_REST_PATH"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpRestPaths" artifactName="/mcp/rpc/*" artifactTypeEnumId="AT_REST_PATH"/>
<!-- MCP Artifact Authz -->
<moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
<moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
<moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
<!-- Add admin user to MCP user group for testing -->
<!-- MCP User Accounts -->
<moqui.security.UserAccount userId="MCP_USER" username="mcp-user" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/>
<moqui.security.UserAccount userId="ADMIN" username="ADMIN" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/>
<!-- Add MCP users to MCP user group -->
<moqui.security.UserGroupMember userGroupId="McpUser" userId="MCP_USER" fromDate="2025-01-01 00:00:00.000"/>
<moqui.security.UserGroupMember userGroupId="McpUser" userId="ADMIN" fromDate="2025-01-01 00:00:00.000"/>
<!-- Add existing demo users to MCP user group for testing -->
<moqui.security.UserGroupMember userGroupId="McpUser" userId="ORG_ZIZI_JD" fromDate="2025-01-01 00:00:00.000"/>
<moqui.security.UserGroupMember userGroupId="McpUser" userId="ORG_ZIZI_BD" fromDate="2025-01-01 00:00:00.000"/>
</entity-facade-xml>
\ No newline at end of file
......
<?xml version="1.0" encoding="UTF-8"?>
<!-- This software is in the public domain under CC0 1.0 Universal plus a
Grant of Patent License.
To the extent possible under law, the author(s) have dedicated all
copyright and related and neighboring rights to this software to the
public domain worldwide. This software is distributed without any warranty.
You should have received a copy of the CC0 Public Domain Dedication
along with this software (see the LICENSE.md file). If not, see
<https://creativecommons.org/publicdomain/zero/1.0/>. -->
<screen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd"
require-authentication="true" track-artifact-hit="false" default-menu-include="false">
<parameter name="jsonrpc"/>
<parameter name="id"/>
<parameter name="method"/>
<parameter name="params"/>
<actions>
<!-- Handle MCP JSON-RPC requests -->
<script><![CDATA[
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
// Check MCP protocol version header
def protocolVersion = ec.web.request.getHeader("MCP-Protocol-Version")
if (!protocolVersion) {
protocolVersion = "2025-03-26" // Default for backwards compatibility
}
// Only handle POST requests for JSON-RPC, GET for SSE streams
if (ec.web.request.method != "POST" && ec.web.request.method != "GET") {
ec.web.sendError(405, "Method Not Allowed: MCP supports POST and GET")
return
}
// Handle GET requests for SSE streams
if (ec.web.request.method == "GET") {
handleSseStream(ec, protocolVersion)
return
}
// Parse JSON-RPC request body if not already in parameters
if (!jsonrpc && !method) {
def requestBody = ec.web.request.getInputStream()?.getText()
if (requestBody) {
def jsonSlurper = new JsonSlurper()
def jsonRequest = jsonSlurper.parseText(requestBody)
jsonrpc = jsonRequest.jsonrpc
id = jsonRequest.id
method = jsonRequest.method
params = jsonRequest.params
}
}
// Validate JSON-RPC version
if (jsonrpc && jsonrpc != "2.0") {
def errorResponse = new JsonBuilder([
jsonrpc: "2.0",
error: [
code: -32600,
message: "Invalid Request: Only JSON-RPC 2.0 supported"
],
id: id
]).toString()
ec.web.sendJsonResponse(errorResponse)
return
}
def result = null
def error = null
try {
// Route to appropriate MCP service
def serviceName = null
switch (method) {
case "initialize":
serviceName = "mo-mcp.McpJsonRpcServices.handle#Initialize"
break
case "tools/list":
serviceName = "mo-mcp.McpJsonRpcServices.handle#ToolsList"
break
case "tools/call":
serviceName = "mo-mcp.McpJsonRpcServices.handle#ToolsCall"
break
case "resources/list":
serviceName = "mo-mcp.McpJsonRpcServices.handle#ResourcesList"
break
case "resources/read":
serviceName = "mo-mcp.McpJsonRpcServices.handle#ResourcesRead"
break
case "ping":
serviceName = "mo-mcp.McpJsonRpcServices.handle#Ping"
break
default:
error = [
code: -32601,
message: "Method not found: ${method}"
]
}
if (serviceName && !error) {
// Call the MCP service
result = ec.service.sync(serviceName, params ?: [:])
}
} catch (Exception e) {
ec.logger.error("MCP JSON-RPC error for method ${method}", e)
error = [
code: -32603,
message: "Internal error: ${e.message}"
]
}
// Build JSON-RPC response
def responseObj = [
jsonrpc: "2.0",
id: id
]
if (error) {
responseObj.error = error
} else {
responseObj.result = result
}
def response = new JsonBuilder(responseObj).toString()
// Check Accept header for response format negotiation
def acceptHeader = ec.web.request.getHeader("Accept") ?: ""
def wantsSse = acceptHeader.contains("text/event-stream")
if (wantsSse && method) {
// Send SSE response for streaming
sendSseResponse(ec, responseObj, protocolVersion)
} else {
// Send regular JSON response
ec.web.sendJsonResponse(response)
}
]]></script>
</actions>
<actions>
<!-- SSE Helper Functions -->
<script><![CDATA[
def handleSseStream(ec, protocolVersion) {
// Set SSE headers
ec.web.response.setContentType("text/event-stream")
ec.web.response.setCharacterEncoding("UTF-8")
ec.web.response.setHeader("Cache-Control", "no-cache")
ec.web.response.setHeader("Connection", "keep-alive")
ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
def writer = ec.web.response.writer
try {
// Send initial connection event
writer.write("event: connected\n")
writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${ec.user.now}\"}\n")
writer.write("\n")
writer.flush()
// Keep connection alive with periodic pings
def count = 0
while (count < 30) { // Keep alive for ~30 seconds
Thread.sleep(1000)
writer.write("event: ping\n")
writer.write("data: {\"timestamp\":\"${ec.user.now}\"}\n")
writer.write("\n")
writer.flush()
count++
}
} catch (Exception e) {
ec.logger.warn("SSE stream interrupted: ${e.message}")
} finally {
writer.close()
}
}
def sendSseResponse(ec, responseObj, protocolVersion) {
// Set SSE headers
ec.web.response.setContentType("text/event-stream")
ec.web.response.setCharacterEncoding("UTF-8")
ec.web.response.setHeader("Cache-Control", "no-cache")
ec.web.response.setHeader("Connection", "keep-alive")
ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
def writer = ec.web.response.writer
def jsonBuilder = new JsonBuilder(responseObj)
try {
// Send the response as SSE event
writer.write("event: response\n")
writer.write("data: ${jsonBuilder.toString()}\n")
writer.write("\n")
writer.flush()
} catch (Exception e) {
ec.logger.error("Error sending SSE response: ${e.message}")
} finally {
writer.close()
}
}
]]></script>
</actions>
<widgets>
<!-- This screen should never render widgets - it handles JSON-RPC requests directly -->
</widgets>
</screen>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<!-- This software is in the public domain under CC0 1.0 Universal plus a
Grant of Patent License.
To the extent possible under law, the author(s) have dedicated all
copyright and related and neighboring rights to this software to the
public domain worldwide. This software is distributed without any warranty.
You should have received a copy of the CC0 Public Domain Dedication
along with this software (see the LICENSE.md file). If not, see
<https://creativecommons.org/publicdomain/zero/1.0/>. -->
<resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/rest-api-3.xsd"
name="mcp" displayName="MCP JSON-RPC API" version="2.0.0"
description="MCP JSON-RPC 2.0 services for Moqui integration">
<resource name="rpc">
<method type="post">
<service name="McpServices.handle#McpRequest"/>
</method>
<method type="get">
<service name="McpServices.mcp#Ping"/>
</method>
</resource>
</resource>
<?xml version="1.0" encoding="UTF-8"?>
<!--
This software is in the public domain under CC0 1.0 Universal plus a
Grant of Patent License.
To the extent possible under law, the author(s) have dedicated all
copyright and related and neighboring rights to this software to the
public domain worldwide. This software is distributed without any warranty.
You should have received a copy of the CC0 Public Domain Dedication
along with this software (see the LICENSE.md file). If not, see
<https://creativecommons.org/publicdomain/zero/1.0/>.
-->
<resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/rest-api-3.xsd"
name="mo-mcp" displayName="Moqui MCP Server API" version="${moqui_version}"
description="Model Context Protocol (MCP) server for Moqui ERP services and entities">
<!-- MCP JSON-RPC Handler -->
<resource name="mcp" description="MCP JSON-RPC 2.0 endpoint (MCP 2025-06-18 compliant)">
<method type="post">
<service name="McpJsonRpcServices.handleJsonRpcRequest"/>
</method>
<method type="get">
<service name="McpJsonRpcServices.handleHttpGetRequest"/>
</method>
</resource>
<!-- Direct MCP Service Access -->
<resource name="McpServices" description="MCP Services">
<resource name="mcp" description="MCP Protocol Operations">
<method type="post">
<service name="McpServices.mcp#Ping"/>
</method>
<method type="post">
<service name="McpServices.mcp#Initialize"/>
</method>
<method type="post">
<service name="McpServices.mcp#ToolsList"/>
</method>
<method type="post">
<service name="McpServices.mcp#ToolsCall"/>
</method>
<method type="post">
<service name="McpServices.mcp#ResourcesList"/>
</method>
<method type="post">
<service name="McpServices.mcp#ResourcesRead"/>
</method>
</resource>
<resource name="discover" description="MCP Discovery Services">
<method type="post">
<service name="McpServices.discoverMcpTools"/>
</method>
<method type="post">
<service name="McpServices.discoverMcpResources"/>
</method>
</resource>
<resource name="execute" description="MCP Tool Execution">
<method type="post">
<service name="McpServices.executeMcpTool"/>
</method>
</resource>
</resource>
</resource>
\ No newline at end of file