Skip to content
Toggle navigation
Toggle navigation
This project
Loading...
Sign in
Ean Schuessler
/
mo-mcp
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Commits
Issue Boards
Files
Commits
Network
Compare
Branches
Tags
1bb29d0c
authored
2025-12-19 17:04:54 -0600
by
Ean Schuessler
Browse Files
Options
Browse Files
Tag
Download
Email Patches
Plain Diff
feat(mcp): Implement recursive screen discovery for MCP tools
1 parent
1f2291ad
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
200 additions
and
319 deletions
data/McpSecuritySeedData.xml
service/McpServices.xml
src/main/groovy/org/moqui/mcp/EnhancedMcpServlet.groovy
src/main/groovy/org/moqui/mcp/McpUtils.groovy
data/McpSecuritySeedData.xml
View file @
1bb29d0
...
...
@@ -15,76 +15,19 @@
<!-- MCP User Groups -->
<moqui.security.UserGroup
userGroupId=
"McpUser"
description=
"MCP Server Users"
/>
<moqui.security.UserGroup
userGroupId=
"MCP_
BUSINESS"
description=
"MCP Business Operations - Curated essential services
"
/>
<moqui.security.UserGroup
userGroupId=
"MCP_
ALL_ACCESS"
description=
"MCP All Access (Testing)
"
/>
<!-- MCP Artifact Groups -->
<moqui.security.ArtifactGroup
artifactGroupId=
"McpServices"
description=
"MCP JSON-RPC Services"
/>
<moqui.security.ArtifactGroup
artifactGroupId=
"McpRestPaths"
description=
"MCP REST API Paths"
/>
<moqui.security.ArtifactGroup
artifactGroupId=
"McpScreenTransitions"
description=
"MCP Screen Transitions"
/>
<moqui.security.ArtifactGroup
artifactGroupId=
"McpBusinessServices"
description=
"MCP Essential Business Services"
/>
<moqui.security.ArtifactGroup
artifactGroupId=
"McpSecurityEntities"
description=
"Security entities needed for permission checks"
/>
<moqui.security.ArtifactGroup
artifactGroupId=
"McpScreens"
description=
"MCP Screen Access"
/>
<moqui.security.ArtifactGroup
artifactGroupId=
"McpScreenTools"
description=
"MCP Screen-based Tools"
/>
<!-- MCP Artifact Group Members -->
<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=
"McpServices"
artifactName=
"mcp#Initialize"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"list#Tools"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"mcp#ToolsCall"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"mcp#Ping"
artifactTypeEnumId=
"AT_SERVICE"
/>
<!-- Screen Discovery and Execution Services -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"McpServices.discover#ScreensAsMcpTools"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"McpServices.convert#ScreenToMcpTool"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"McpServices.execute#ScreenAsMcpTool"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"McpServices.execute#ScreenAsMcpTool"
artifactTypeEnumId=
"AT_SERVICE"
/>
<!-- MCP Test Screen -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpScreens"
artifactName=
"component://moqui-mcp-2/screen/McpTestScreen.xml"
artifactTypeEnumId=
"AT_XML_SCREEN"
/>
<!-- Essential Business Services -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.order.OrderServices.create#Order"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.party.PartyServices.find#Party"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.ledger.LedgerServices.find#PartyAcctgPreference"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"org.moqui.impl.BasicServices.send#Email"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"org.moqui.impl.BasicServices.create#CommunicationEvent"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.product.ProductServices.find#ProductByIdValue"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.product.AssetServices.get#AvailableInventory"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"McpServices.list#Products"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.ledger.LedgerServices.find#GlAccount"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.product.PriceServices.get#ProductPrice"
artifactTypeEnumId=
"AT_SERVICE"
/>
<!-- Entity Services -->
<!--
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.find#Entity" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.create#Entity" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.update#Entity" artifactTypeEnumId="AT_SERVICE"/>
<moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.delete#Entity" artifactTypeEnumId="AT_SERVICE"/>
-->
<!-- Essential Business Entities -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.order.OrderHeader"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.order.OrderItem"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.party.Party"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.party.FindPartyView"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.account.Customer"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"UserAccount"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.ledger.FinancialAccount"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.product.Product"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"mantle.invoice.Invoice"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"moqui.server.CommunicationEvent"
artifactTypeEnumId=
"AT_ENTITY"
/>
<!-- MCP Test Services -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpBusinessServices"
artifactName=
"org.moqui.mcp.McpTestServices.*"
artifactTypeEnumId=
"AT_SERVICE"
/>
<!-- Visit Entity Access -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"moqui.server.Visit"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"create#moqui.server.Visit"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"update#moqui.server.Visit"
artifactTypeEnumId=
"AT_ENTITY"
/>
<!-- Security Entity Access for permission checking -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpSecurityEntities"
artifactName=
"moqui.security.ArtifactGroupMember"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpSecurityEntities"
artifactName=
"moqui.security.UserGroupMember"
artifactTypeEnumId=
"AT_ENTITY"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpSecurityEntities"
artifactName=
"moqui.security.ArtifactAuthz"
artifactTypeEnumId=
"AT_ENTITY"
/>
<!-- Basic Services -->
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"org.moqui.impl.BasicServices.get#ServerNodeInfo"
artifactTypeEnumId=
"AT_SERVICE"
/>
<moqui.security.ArtifactGroupMember
artifactGroupId=
"McpServices"
artifactName=
"org.moqui.impl.BasicServices.get#SystemInfo"
artifactTypeEnumId=
"AT_SERVICE"
/>
...
...
@@ -94,41 +37,21 @@
<!-- MCP Artifact Authz -->
<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"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"McpUser"
artifactGroupId=
"McpScreens"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_VIEW"
/>
<!--
<moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpScreenTransitions" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
<moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpScreenTools" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
-->
<!-- Give ALL users access to security entities needed for permission checks -->
<!--
<moqui.security.ArtifactAuthz userGroupId="ALL_USERS" artifactGroupId="McpSecurityEntities" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/>
-->
<!-- Ensure ADMIN user always has access to
security entities needed for permission check
s -->
<!-- Ensure ADMIN user always has access to
MCP service
s -->
<moqui.security.ArtifactAuthz
userGroupId=
"ADMIN"
artifactGroupId=
"McpServices"
authzTypeEnumId=
"AUTHZT_ALWAYS"
authzActionEnumId=
"AUTHZA_ALL"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"ADMIN"
artifactGroupId=
"McpScreens"
authzTypeEnumId=
"AUTHZT_ALWAYS"
authzActionEnumId=
"AUTHZA_ALL"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"ADMIN"
artifactGroupId=
"McpScreenTools"
authzTypeEnumId=
"AUTHZT_ALWAYS"
authzActionEnumId=
"AUTHZA_ALL"
/>
<!-- Explicit permission for screen execution service -->
<!-- <moqui.security.ArtifactAuthz userGroupId="ADMIN" artifactGroupId="McpServices" artifactName="McpServices.execute#ScreenAsMcpTool" authzTypeEnumId="AUTHZT_ALWAYS" authzActionEnumId="AUTHZA_ALL"/> -->
<!-- MCP Business Group Authz -->
<moqui.security.ArtifactAuthz
userGroupId=
"MCP_BUSINESS"
artifactGroupId=
"McpServices"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_ALL"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"MCP_BUSINESS"
artifactGroupId=
"McpBusinessServices"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_ALL"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"MCP_BUSINESS"
artifactGroupId=
"McpScreens"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_ALL"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"MCP_BUSINESS"
artifactGroupId=
"McpRestPaths"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_ALL"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"MCP_BUSINESS"
artifactGroupId=
"McpScreens"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_ALL"
/>
<moqui.security.ArtifactAuthz
userGroupId=
"MCP_BUSINESS"
artifactGroupId=
"McpScreenTools"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_ALL"
/>
<!-- MCP User Accounts -->
<moqui.security.UserAccount
userId=
"MCP_USER"
username=
"mcp-user"
currentPassword=
"16ac58bbfa332c1c55bd98b53e60720bfa90d394"
passwordHashType=
"SHA"
/>
<moqui.security.UserAccount
userId=
"MCP_BUSINESS"
username=
"mcp-business"
currentPassword=
"16ac58bbfa332c1c55bd98b53e60720bfa90d394"
passwordHashType=
"SHA"
/>
<!-- Add MCP users to MCP user groups -->
<moqui.security.UserGroupMember
userGroupId=
"McpUser"
userId=
"MCP_USER"
fromDate=
"2025-01-01 00:00:00.000"
/>
<moqui.security.UserGroupMember
userGroupId=
"McpUser"
userId=
"JohnSales"
fromDate=
"2025-01-01 00:00:00.000"
/>
<moqui.security.UserGroupMember
userGroupId=
"MCP_BUSINESS"
userId=
"MCP_BUSINESS"
fromDate=
"2025-01-01 00:00:00.000"
/>
<moqui.security.UserGroupMember
userGroupId=
"MCP_ALL_ACCESS"
userId=
"JohnSales"
fromDate=
"2025-01-01 00:00:00.000"
/>
<!-- Permissions for ALL_ACCESS group -->
<moqui.security.ArtifactAuthz
userGroupId=
"MCP_ALL_ACCESS"
artifactGroupId=
"McpServices"
authzTypeEnumId=
"AUTHZT_ALLOW"
authzActionEnumId=
"AUTHZA_ALL"
/>
<!-- ADMIN user doesn't need to be in MCP groups - should have full access by default -->
<!-- Add existing demo users to MCP business group for focused testing -->
...
...
service/McpServices.xml
View file @
1bb29d0
...
...
@@ -137,7 +137,6 @@
def startTime = System.currentTimeMillis()
// Handle stubbed MCP protocol methods by routing to actual Moqui services
// Map contains MCP protocol method names to their actual service implementations
def protocolMethodMappings = [
"tools/list": "McpServices.list#Tools",
"tools/call": "McpServices.mcp#ToolsCall",
...
...
@@ -157,123 +156,58 @@
ec.logger.info("MCP ToolsCall: Routing protocol method ${name} to ${protocolMethodMappings[name]}")
def targetServiceName = protocolMethodMappings[name]
// Special handling for tools/call to avoid infinite recursion
if (name == "tools/call") {
// Extract the actual tool name and arguments from arguments
def actualToolName = arguments?.name
def actualArguments = arguments?.arguments
if (!actualToolName) throw new Exception("tools/call requires 'name' parameter in arguments")
if (!actualToolName) {
throw new Exception("tools/call requires 'name' parameter in arguments")
}
// Ensure sessionId is always passed through in arguments
if (actualArguments instanceof Map) {
actualArguments.sessionId = sessionId
} else {
actualArguments = [sessionId: sessionId]
}
// Check if this is a screen tool (starts with screen_ or moqui_) - route to screen execution service
def screenPath = org.moqui.mcp.McpUtils.getScreenPath(actualToolName)
def isLegacyScreen = actualToolName.startsWith("screen_")
if (screenPath || isLegacyScreen) {
ec.logger.info("MCP ToolsCall: Routing screen tool '${actualToolName}' to executeScreenAsMcpTool")
if (actualArguments instanceof Map) actualArguments.sessionId = sessionId
else actualArguments = [sessionId: sessionId]
// Check if this is a screen tool (starts with moqui_)
def screenPath = null
def subscreenName = null
if (isLegacyScreen) {
// Decode legacy screen path from tool name
def toolNameSuffix = actualToolName.substring(7) // Remove "screen_" prefix
// Check if this is a subscreen (contains dot after initial prefix)
if (toolNameSuffix.contains('.')) {
def lastDotIndex = toolNameSuffix.lastIndexOf('.')
def parentPath = toolNameSuffix.substring(0, lastDotIndex)
subscreenName = toolNameSuffix.substring(lastDotIndex + 1)
screenPath = "component://" + parentPath.replace('_', '/') + ".xml"
} else {
screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml"
}
} else {
// For moqui_ tools, check existence and fallback to subscreen
// Walk down from component root to find the actual screen file and the subscreen path below it
def cleanName = actualToolName.substring(6) // Remove moqui_
def parts = cleanName.split('_').toList()
def component = parts[0]
def currentPath = "component://${component}/screen"
def subNameParts = []
// Parts start from index 1 (after component)
for (int i = 1; i < parts.size(); i++) {
def part = parts[i]
def nextPath = "${currentPath}/${part}"
if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) {
currentPath = nextPath
// Reset subNameParts when we find a deeper screen file
subNameParts = []
} else {
subNameParts << part
}
}
screenPath = currentPath + ".xml"
if (subNameParts) subscreenName = subNameParts.join("_")
if (actualToolName.startsWith("moqui_")) {
def decoded = org.moqui.mcp.McpUtils.decodeToolName(actualToolName, ec)
screenPath = decoded.screenPath
subscreenName = decoded.subscreenName
}
if (screenPath) {
ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}, subscreen=${subscreenName}")
// Call screen execution service with decoded parameters
def screenCallParams = [
screenPath: screenPath,
parameters: actualArguments,
//actualArguments?.arguments ?: [:],
parameters: actualArguments,
renderMode: actualArguments?.renderMode ?: "html",
sessionId: sessionId
]
if (subscreenName) {
screenCallParams.subscreenName = subscreenName
}
if (subscreenName) screenCallParams.subscreenName = subscreenName
ec.logger.info("EXECUTESCREEN ${screenCallParams}")
return ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters(screenCallParams)
.call()
.parameters(screenCallParams).call()
} else {
// For non-screen tools, check if it's another protocol method
def actualTargetServiceName = protocolMethodMappings[actualToolName]
if (actualTargetServiceName) {
ec.logger.info("MCP ToolsCall: Routing tools/call with name '${actualToolName}' to ${actualTargetServiceName}")
return ec.service.sync().name(actualTargetServiceName)
.parameters(actualArguments ?: [:])
.call()
.parameters(actualArguments ?: [:]).call()
} else {
// Fallback: check if it's a Moqui service
if (ec.service.isServiceDefined(actualToolName)) {
def serviceResult = ec.service.sync().name(actualToolName).parameters(actualArguments ?: [:]).call()
return [result: [content: [[type: "text", text: new JsonBuilder(serviceResult).toString()]], isError: false]]
}
throw new Exception("Unknown tool name: ${actualToolName}")
}
}
} else {
// For other protocol methods, call the target service with provided arguments
ec.logger.info("MCP ToolsCall: ${targetServiceName} ${arguments}")
def serviceResult = ec.service.sync().name(targetServiceName)
.parameters(arguments ?: [:])
.call()
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Convert result to MCP format
def content = []
if (serviceResult?.result) {
content << [
type: "text",
text: new groovy.json.JsonBuilder(serviceResult.result).toString()
]
def serviceResult = ec.service.sync().name(targetServiceName).parameters(arguments ?: [:]).call()
def actualRes = serviceResult?.result ?: serviceResult
// Ensure standard MCP response format with content array
if (actualRes instanceof Map && actualRes.content && actualRes.content instanceof List) {
result = actualRes
} else {
result = [ content: [[type: "text", text: new groovy.json.JsonBuilder(actualRes).toString()]], isError: false ]
}
result = [
content: content,
isError: false
]
return
}
}
...
...
@@ -282,42 +216,21 @@
def screenPath = null
def subscreenName = null
if (name.startsWith("moqui_")) {
def cleanName = name.substring(6)
def parts = cleanName.split('_').toList()
def component = parts[0]
def currentPath = "component://${component}/screen"
def subNameParts = []
for (int i = 1; i < parts.size(); i++) {
def part = parts[i]
def nextPath = "${currentPath}/${part}"
if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) {
currentPath = nextPath
subNameParts = []
} else {
subNameParts << part
}
}
screenPath = currentPath + ".xml"
if (subNameParts) subscreenName = subNameParts.join("_")
} else {
screenPath = org.moqui.mcp.McpUtils.getScreenPath(name)
def decoded = org.moqui.mcp.McpUtils.decodeToolName(name, ec)
screenPath = decoded.screenPath
subscreenName = decoded.subscreenName
}
if (screenPath) {
ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}, subscreen: ${subscreenName}")
// Now call the screen tool with proper user context
def screenParams = arguments ?: [:]
// Use requested render mode from arguments, default to text for LLM-friendly output
def renderMode = screenParams.remove('renderMode') ?: "html"
def serviceCallParams = [screenPath: screenPath, parameters: screenParams, renderMode: renderMode, sessionId: sessionId]
if (subscreenName) {
serviceCallParams.subscreenName = subscreenName
}
if (subscreenName) serviceCallParams.subscreenName = subscreenName
ec.logger.info("SCREENASTOOL ${serviceCallParams}")
serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
def
serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters(serviceCallParams)
.call()
...
...
@@ -434,7 +347,7 @@
</service>
<service
verb=
"mcp"
noun=
"ResourcesList"
authenticate=
"false"
allow-remote=
"true"
transaction-timeout=
"60"
>
<description>
Handle MCP resources/list request with Moqui entity discovery
</description>
<description>
Handle MCP resources/list request with Moqui entity discovery
based on user permissions
</description>
<in-parameters>
<parameter
name=
"sessionId"
/>
<parameter
name=
"cursor"
/>
...
...
@@ -445,72 +358,31 @@
<actions>
<script>
<![CDATA[
import org.moqui.context.ExecutionContext
import org.moqui.impl.context.UserFacadeImpl.UserInfo
ExecutionContext ec = context.ec
// Build list of available entities as resources
def resources = []
UserInfo adminUserInfo = null
// Store original user context before switching to ADMIN
def originalUsername = ec.user.username
def originalUserId = ec.user.userId
def userGroups = ec.user.getUserGroupIdSet().collect { it }
// Use curated list of commonly used entities instead of discovering all entities
def availableResources = []
ec.logger.debug("MCP ResourcesList:
Starting permissions-based entity discovery
${userGroups}")
ec.logger.debug("MCP ResourcesList:
Discovering entities for user groups:
${userGroups}")
// Get user's accessible entities using Moqui's optimized ArtifactAuthzCheckView
def userAccessibleEntities = null as Set<String>
adminUserInfo = null
try {
adminUserInfo = ec.user.pushUser("ADMIN")
// Use ArtifactAuthzCheckView to find all entities the user has permission for
// This is the "Moqui Way" - rely on the security system to tell us what is accessible
def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
.condition("userGroupId", userGroups)
.condition("artifactTypeEnumId", "AT_ENTITY")
.useCache(true)
.disableAuthz()
.list()
userAccessibleEntities = aacvList.collect { it.artifactName } as Set
<String>
} finally {
if (adminUserInfo != null) {
ec.user.popUser()
}
}
// Helper function to check if user has permission to an entity
def userHasEntityPermission = { entityName ->
// Use pre-computed accessible entities set for O(1) lookup
return userAccessibleEntities != null
&&
userAccessibleEntities.contains(entityName.toString())
}
// Add all permitted entities including ViewEntities for LLM convenience
def allEntityNames = ec.entity.getAllEntityNames()
def allViewNames = [] as Set
<String>
// Get ViewEntities by checking entity definitions for view entities
def entityInfoList = ec.entity.getAllEntityInfo(0, true) // includeViewEntities=true
for (entityInfo in entityInfoList) {
if (entityInfo.isViewEntity) {
allViewNames.add(entityInfo.entityName)
}
}
// Combine real entities and ViewEntities
def allAccessibleEntities = allEntityNames + allViewNames
for (entityName in allAccessibleEntities) {
if (userHasEntityPermission(entityName)) {
for (def aacv in aacvList) {
def entityName = aacv.artifactName
// Basic sanity check to ensure entity is actually defined
if (ec.entity.isEntityDefined(entityName)) {
def description = "Moqui entity: ${entityName}"
if (entityName.contains("View")) {
description = "Moqui ViewEntity: ${entityName}
(pre-joined data for LLM convenience)
"
description = "Moqui ViewEntity: ${entityName}"
}
ec.logger.debug("MCP ResourcesList: Adding entity: ${entityName}")
availableResources << [
uri: "entity://${entityName}",
name: entityName,
...
...
@@ -1401,22 +1273,54 @@ def startTime = System.currentTimeMillis()
def subscreens = []
def tools = []
def currentPath = path
def userGroups = ec.user.getUserGroupIdSet().collect { it }
// Logic to find screens
if (!path || path == "/" || path == "root") {
currentPath = "root"
// Return known root apps
// In a real implementation, we'd discover these from components
def roots = ["PopCommerce", "SimpleScreens", "HiveMind", "Mantle"]
// Discover top-level applications from ArtifactAuthzCheckView
// We look for XML screens that the user has view permission for and are likely app roots
def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
.condition("userGroupId", userGroups)
.condition("artifactTypeEnumId", "AT_XML_SCREEN")
.useCache(true)
.disableAuthz()
.list()
for (root in roots) {
def toolName = "moqui_${root}"
def rootScreens = new HashSet()
for (def aacv in aacvList) {
def name = aacv.artifactName
if (name.startsWith("component://") && name.endsWith(".xml")) {
def parts = name.substring(12).split('/')
if (parts.length >
= 3
&&
parts[1] == "screen") {
def filename = parts[parts.length - 1]
def componentName = parts[0]
// Match component://Name/screen/Name.xml OR component://Name/screen/NameAdmin.xml OR component://Name/screen/NameRoot.xml
if (filename == componentName + ".xml" || filename == componentName + "Admin.xml" || filename == componentName + "Root.xml" || filename == "webroot.xml") {
rootScreens.add(name)
}
}
}
}
for (def screenPath in rootScreens) {
def toolName = McpUtils.getToolName(screenPath)
if (toolName) {
subscreens
<<
[
name: toolName,
path: toolName,
description: "Application: ${root
}"
description: "Application Root: ${screenPath
}"
]
}
}
// Fallback to basic apps if nothing found (to ensure discoverability)
if (subscreens.isEmpty()) {
["PopCommerce", "SimpleScreens"].each { root ->
def toolName = "moqui_${root}"
subscreens
<<
[ name: toolName, path: toolName, description: "Application: ${root}" ]
}
}
} else {
// Resolve path
def screenPath = path.startsWith("moqui_") ? McpUtils.getScreenPath(path) : null
...
...
@@ -1437,21 +1341,22 @@ def startTime = System.currentTimeMillis()
}
if (screenPath) {
def currentScreenPath = screenPath
if (!currentScreenPath.endsWith(".xml")) currentScreenPath += ".xml"
// Check if screen exists, if not try to resolve as subscreen
if (!ec.resource.getLocationReference(
s
creenPath).getExists()) {
def lastSlash =
s
creenPath.lastIndexOf('/')
if (!ec.resource.getLocationReference(
currentS
creenPath).getExists()) {
def lastSlash =
currentS
creenPath.lastIndexOf('/')
if (lastSlash > 0) {
def parentPath =
s
creenPath.substring(0, lastSlash) + ".xml"
def sub
screenName = s
creenPath.substring(lastSlash + 1).replace('.xml', '')
def parentPath =
currentS
creenPath.substring(0, lastSlash) + ".xml"
def sub
Name = currentS
creenPath.substring(lastSlash + 1).replace('.xml', '')
if (ec.resource.getLocationReference(parentPath).getExists()) {
// Found parent, now find the subscreen location
try {
def parentDef = ec.screen.getScreenDefinition(parentPath)
def subscreenItem = parentDef?.getSubscreensItem(sub
screen
Name)
def subscreenItem = parentDef?.getSubscreensItem(subName)
if (subscreenItem
&&
subscreenItem.getLocation()) {
ec.logger.info("Redirecting browse from ${
s
creenPath} to ${subscreenItem.getLocation()}")
s
creenPath = subscreenItem.getLocation()
ec.logger.info("Redirecting browse from ${
currentS
creenPath} to ${subscreenItem.getLocation()}")
currentS
creenPath = subscreenItem.getLocation()
}
} catch (Exception e) {
ec.logger.warn("Error resolving subscreen location: ${e.message}")
...
...
@@ -1461,10 +1366,7 @@ def startTime = System.currentTimeMillis()
}
try {
// First, add the current screen itself as a tool if it's executable
// Use baseToolName if available to preserve context (e.g. moqui_PopCommerce_...)
// even if the implementation is in another component (e.g. SimpleScreens)
def currentToolName = baseToolName ?: McpUtils.getToolName(screenPath)
def currentToolName = baseToolName ?: McpUtils.getToolName(currentScreenPath)
tools
<<
[
name: currentToolName,
description: "Execute screen: ${currentToolName}",
...
...
@@ -1472,25 +1374,17 @@ def startTime = System.currentTimeMillis()
]
// Then look for subscreens
// Use getScreenInfoList with depth/mode? to ensure implicit subscreens are loaded
// The original list#Tools used getScreenInfoList(path, 1)
def screenInfoList = null
def screenDef = null
try {
screen
InfoList = ec.screen.getScreenInfoList(screenPath, 1
)
screen
Def = ec.screen.getScreenDefinition(currentScreenPath
)
} catch (Exception e) {
ec.logger.
debug("getScreenInfoList failed, trying getScreenInfo
: ${e.message}")
ec.logger.
warn("Error getting screen definition for ${currentScreenPath}
: ${e.message}")
}
def screenInfo = screenInfoList ? screenInfoList.first() : ec.screen.getScreenInfo(screenPath)
if (screenInfo?.subscreenInfoByName) {
for (entry in screenInfo.subscreenInfoByName) {
def subName = entry.key
def subInfo = entry.value
// Construct sub-tool name by extending the parent path
// This assumes subscreens are structurally nested in the tool name
// e.g. moqui_PopCommerce_Admin -> moqui_PopCommerce_Admin_Catalog
if (screenDef) {
def subItems = screenDef.getSubscreensItemsSorted()
for (subItem in subItems) {
def subName = subItem.getName()
def parentToolName = baseToolName ?: McpUtils.getToolName(screenPath)
def subToolName = parentToolName + "_" + subName
...
...
@@ -1561,7 +1455,7 @@ def startTime = System.currentTimeMillis()
</service>
<service
verb=
"mcp"
noun=
"GetScreenDetails"
authenticate=
"false"
allow-remote=
"true"
transaction-timeout=
"30"
>
<description>
Get detailed schema and usage info for a specific screen tool.
</description>
<description>
Get detailed schema and usage info for a specific screen tool
, including inferred parameters from entities
.
</description>
<in-parameters>
<parameter
name=
"name"
required=
"true"
><description>
Tool name (e.g. moqui_PopCommerce_...)
</description></parameter>
<parameter
name=
"sessionId"
/>
...
...
@@ -1575,39 +1469,67 @@ def startTime = System.currentTimeMillis()
import org.moqui.mcp.McpUtils
ExecutionContext ec = context.ec
def screenPath = McpUtils.getScreenPath(name)
def decoded = McpUtils.decodeToolName(name, ec)
def screenPath = decoded.screenPath
ec.logger.info("GetScreenDetails: name=${name} ->
screenPath=${screenPath}")
def toolDef = null
if (screenPath) {
try {
def parameters = [:]
// Use getScreenDefinition for stable access to parameters
def screenDef = ec.screen.getScreenDefinition(screenPath)
def properties = [:]
def required = []
def screenDef = null
try {
screenDef = ec.screen.getScreenDefinition(screenPath)
} catch (Exception e) {
ec.logger.warn("Error getting screen definition for ${screenPath}: ${e.message}")
}
if (screenDef
&&
screenDef.screenNode) {
// Extract parameters from XML node
def parameterNodes = screenDef.screenNode.children("parameter")
for (node in parameterNodes) {
// Helper to convert Moqui type to JSON Schema type
def getJsonType = { moquiType ->
def typeRes = ec.service.sync().name("McpServices.convert#MoquiTypeToJsonSchemaType")
.parameter("moquiType", moquiType).call()
return typeRes?.jsonSchemaType ?: "string"
}
// 1. Extract explicit parameters from screen XML
screenDef.screenNode.children("parameter").each { node ->
def paramName = node.attribute("name")
p
arameter
s[paramName] = [
p
ropertie
s[paramName] = [
type: "string",
description: "Screen Parameter"
]
if (node.attribute("required") == "true") required
<
< paramName
}
// Also extract transition parameters if possible
def transitionNodes = screenDef.screenNode.children("transition")
for (node in transitionNodes) {
// Transition parameters
def tParams = node.children("parameter")
for (tp in tParams) {
//
2.
Extract
transition
parameters
(actions/links)
screenDef.screenNode.children("transition").each
{
node
-
>
node.children("parameter").each { tp ->
def tpName = tp.attribute("name")
if (!p
arameter
s[tpName]) {
p
arameter
s[tpName] = [
if (!p
ropertie
s[tpName]) {
p
ropertie
s[tpName] = [
type: "string",
description: "Transition Parameter for ${node.attribute('name')}"
]
if (tp.attribute("required") == "true") required
<
< tpName
}
}
}
//
3.
Infer
parameters
from
form-list
or
form-single
if
they
reference
an
entity
screenDef.screenNode.depthFirst().findAll
{
it.name()
==
"form-single"
||
it.name()
==
"form-list"
}.each
{
formNode
-
>
def entityName = formNode.attribute("entity-name")
if (entityName
&&
ec.entity.isEntityDefined(entityName)) {
def entityDef = ec.entity.getEntityDefinition(entityName)
entityDef.getAllFieldNames().each { fieldName ->
if (!properties[fieldName]) {
def fieldInfo = entityDef.getFieldNode(fieldName)
properties[fieldName] = [
type: getJsonType(fieldInfo.attribute("type")),
description: "Inferred from entity ${entityName}"
]
}
}
}
}
...
...
@@ -1618,7 +1540,8 @@ def startTime = System.currentTimeMillis()
description: "Full details for ${name} (${screenPath})",
inputSchema: [
type: "object",
properties: parameters
properties: properties,
required: required
]
]
} catch (Exception e) {
...
...
src/main/groovy/org/moqui/mcp/EnhancedMcpServlet.groovy
View file @
1bb29d0
...
...
@@ -582,12 +582,12 @@ try {
// Validate Accept header per MCP 2025-11-25 spec requirement #2
// Client MUST include Accept header listing both application/json and text/event-stream
if
(!
acceptHeader
||
!(
acceptHeader
.
contains
(
"application/json"
)
||
acceptHeader
.
contains
(
"text/event-stream"
)))
{
if
(!
acceptHeader
||
!(
acceptHeader
.
contains
(
"application/json"
)
&&
acceptHeader
.
contains
(
"text/event-stream"
)))
{
response
.
setStatus
(
HttpServletResponse
.
SC_BAD_REQUEST
)
response
.
setContentType
(
"application/json"
)
response
.
writer
.
write
(
JsonOutput
.
toJson
([
jsonrpc:
"2.0"
,
error:
[
code:
-
32600
,
message:
"Accept header must include
application/json and/or
text/event-stream per MCP 2025-11-25 spec"
],
error:
[
code:
-
32600
,
message:
"Accept header must include
both application/json and
text/event-stream per MCP 2025-11-25 spec"
],
id:
null
]))
return
...
...
src/main/groovy/org/moqui/mcp/McpUtils.groovy
View file @
1bb29d0
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, 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
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package
org.moqui.mcp
import
org.moqui.context.ExecutionContext
import
org.moqui.util.MNode
class
McpUtils
{
/**
* Convert a Moqui screen path to an MCP tool name.
* Preserves case to ensure reversibility.
* Format: moqui_<Component>_<Path_Parts>
* Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog
*/
static
String
getToolName
(
String
screenPath
)
{
if
(!
screenPath
)
return
null
// Strip component:// prefix and .xml suffix
String
cleanPath
=
screenPath
if
(
cleanPath
.
startsWith
(
"component://"
))
cleanPath
=
cleanPath
.
substring
(
12
)
if
(
cleanPath
.
endsWith
(
".xml"
))
cleanPath
=
cleanPath
.
substring
(
0
,
cleanPath
.
length
()
-
4
)
List
<
String
>
parts
=
cleanPath
.
split
(
'/'
).
toList
()
// Remove 'screen' if it's the second part (standard structure: component/screen/...)
if
(
parts
.
size
()
>
1
&&
parts
[
1
]
==
"screen"
)
{
parts
.
remove
(
1
)
}
// Join with underscores and prefix
return
"moqui_"
+
parts
.
join
(
'_'
)
}
...
...
@@ -37,20 +46,46 @@ class McpUtils {
String
cleanName
=
toolName
.
substring
(
6
)
// Remove moqui_
List
<
String
>
parts
=
cleanName
.
split
(
'_'
).
toList
()
if
(
parts
.
size
()
<
1
)
return
null
String
component
=
parts
[
0
]
// If there's only one part (e.g. moqui_MyComponent), it might be a root or invalid
// But usually we expect at least component and screen
if
(
parts
.
size
()
==
1
)
{
// Fallback for component roots? unlikely to be a valid screen path without 'screen' dir
return
"component://${component}/screen/${component}.xml"
}
// Re-insert 'screen' directory which is standard
String
path
=
parts
.
subList
(
1
,
parts
.
size
()).
join
(
'/'
)
return
"component://${component}/screen/${path}.xml"
}
/**
* Decodes a tool name into a screen path and potential subscreen path by walking the component structure.
* This handles cases where a single XML file contains multiple nested subscreens.
*/
static
Map
decodeToolName
(
String
toolName
,
ExecutionContext
ec
)
{
if
(!
toolName
||
!
toolName
.
startsWith
(
"moqui_"
))
return
[:]
String
cleanName
=
toolName
.
substring
(
6
)
// Remove moqui_
List
<
String
>
parts
=
cleanName
.
split
(
'_'
).
toList
()
String
component
=
parts
[
0
]
String
currentPath
=
"component://${component}/screen"
List
<
String
>
subNameParts
=
[]
// Walk down the parts to find where the XML file ends and subscreens begin
for
(
int
i
=
1
;
i
<
parts
.
size
();
i
++)
{
String
part
=
parts
[
i
]
String
nextPath
=
"${currentPath}/${part}"
if
(
ec
.
resource
.
getLocationReference
(
nextPath
+
".xml"
).
getExists
())
{
currentPath
=
nextPath
subNameParts
=
[]
// Reset subscreens if we found a deeper file
}
else
{
subNameParts
<<
part
}
}
return
[
screenPath:
currentPath
+
".xml"
,
subscreenName:
subNameParts
?
subNameParts
.
join
(
"_"
)
:
null
]
}
}
...
...
Please
register
or
sign in
to post a comment