feat(mcp): Implement recursive screen discovery for MCP tools
Showing
4 changed files
with
200 additions
and
319 deletions
| ... | @@ -15,76 +15,19 @@ | ... | @@ -15,76 +15,19 @@ |
| 15 | 15 | ||
| 16 | <!-- MCP User Groups --> | 16 | <!-- MCP User Groups --> |
| 17 | <moqui.security.UserGroup userGroupId="McpUser" description="MCP Server Users"/> | 17 | <moqui.security.UserGroup userGroupId="McpUser" description="MCP Server Users"/> |
| 18 | <moqui.security.UserGroup userGroupId="MCP_BUSINESS" description="MCP Business Operations - Curated essential services"/> | 18 | <moqui.security.UserGroup userGroupId="MCP_ALL_ACCESS" description="MCP All Access (Testing)"/> |
| 19 | 19 | ||
| 20 | <!-- MCP Artifact Groups --> | 20 | <!-- MCP Artifact Groups --> |
| 21 | <moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP JSON-RPC Services"/> | 21 | <moqui.security.ArtifactGroup artifactGroupId="McpServices" description="MCP JSON-RPC Services"/> |
| 22 | <moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST API Paths"/> | 22 | <moqui.security.ArtifactGroup artifactGroupId="McpRestPaths" description="MCP REST API Paths"/> |
| 23 | <moqui.security.ArtifactGroup artifactGroupId="McpScreenTransitions" description="MCP Screen Transitions"/> | ||
| 24 | <moqui.security.ArtifactGroup artifactGroupId="McpBusinessServices" description="MCP Essential Business Services"/> | ||
| 25 | <moqui.security.ArtifactGroup artifactGroupId="McpSecurityEntities" description="Security entities needed for permission checks"/> | ||
| 26 | <moqui.security.ArtifactGroup artifactGroupId="McpScreens" description="MCP Screen Access"/> | ||
| 27 | <moqui.security.ArtifactGroup artifactGroupId="McpScreenTools" description="MCP Screen-based Tools"/> | ||
| 28 | 23 | ||
| 29 | <!-- MCP Artifact Group Members --> | 24 | <!-- MCP Artifact Group Members --> |
| 30 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.*" artifactTypeEnumId="AT_SERVICE"/> | 25 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.*" artifactTypeEnumId="AT_SERVICE"/> |
| 31 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#Ping" artifactTypeEnumId="AT_SERVICE"/> | 26 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="mcp#Initialize" artifactTypeEnumId="AT_SERVICE"/> |
| 32 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.handle#McpRequest" artifactTypeEnumId="AT_SERVICE"/> | 27 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="list#Tools" artifactTypeEnumId="AT_SERVICE"/> |
| 33 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#Initialize" artifactTypeEnumId="AT_SERVICE"/> | 28 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="mcp#ToolsCall" artifactTypeEnumId="AT_SERVICE"/> |
| 34 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ToolsList" artifactTypeEnumId="AT_SERVICE"/> | 29 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="mcp#Ping" artifactTypeEnumId="AT_SERVICE"/> |
| 35 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ToolsCall" artifactTypeEnumId="AT_SERVICE"/> | ||
| 36 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ResourcesList" artifactTypeEnumId="AT_SERVICE"/> | ||
| 37 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.mcp#ResourcesRead" artifactTypeEnumId="AT_SERVICE"/> | ||
| 38 | 30 | ||
| 39 | <!-- Screen Discovery and Execution Services --> | ||
| 40 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.discover#ScreensAsMcpTools" artifactTypeEnumId="AT_SERVICE"/> | ||
| 41 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.convert#ScreenToMcpTool" artifactTypeEnumId="AT_SERVICE"/> | ||
| 42 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.execute#ScreenAsMcpTool" artifactTypeEnumId="AT_SERVICE"/> | ||
| 43 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="McpServices.execute#ScreenAsMcpTool" artifactTypeEnumId="AT_SERVICE"/> | ||
| 44 | |||
| 45 | <!-- MCP Test Screen --> | ||
| 46 | <moqui.security.ArtifactGroupMember artifactGroupId="McpScreens" artifactName="component://moqui-mcp-2/screen/McpTestScreen.xml" artifactTypeEnumId="AT_XML_SCREEN"/> | ||
| 47 | |||
| 48 | <!-- Essential Business Services --> | ||
| 49 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.order.OrderServices.create#Order" artifactTypeEnumId="AT_SERVICE"/> | ||
| 50 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.party.PartyServices.find#Party" artifactTypeEnumId="AT_SERVICE"/> | ||
| 51 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.ledger.LedgerServices.find#PartyAcctgPreference" artifactTypeEnumId="AT_SERVICE"/> | ||
| 52 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="org.moqui.impl.BasicServices.send#Email" artifactTypeEnumId="AT_SERVICE"/> | ||
| 53 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="org.moqui.impl.BasicServices.create#CommunicationEvent" artifactTypeEnumId="AT_SERVICE"/> | ||
| 54 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.product.ProductServices.find#ProductByIdValue" artifactTypeEnumId="AT_SERVICE"/> | ||
| 55 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.product.AssetServices.get#AvailableInventory" artifactTypeEnumId="AT_SERVICE"/> | ||
| 56 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="McpServices.list#Products" artifactTypeEnumId="AT_SERVICE"/> | ||
| 57 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.ledger.LedgerServices.find#GlAccount" artifactTypeEnumId="AT_SERVICE"/> | ||
| 58 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.product.PriceServices.get#ProductPrice" artifactTypeEnumId="AT_SERVICE"/> | ||
| 59 | <!-- Entity Services --> | ||
| 60 | <!-- | ||
| 61 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.find#Entity" artifactTypeEnumId="AT_SERVICE"/> | ||
| 62 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.create#Entity" artifactTypeEnumId="AT_SERVICE"/> | ||
| 63 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.update#Entity" artifactTypeEnumId="AT_SERVICE"/> | ||
| 64 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.delete#Entity" artifactTypeEnumId="AT_SERVICE"/> | ||
| 65 | --> | ||
| 66 | |||
| 67 | <!-- Essential Business Entities --> | ||
| 68 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.order.OrderHeader" artifactTypeEnumId="AT_ENTITY"/> | ||
| 69 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.order.OrderItem" artifactTypeEnumId="AT_ENTITY"/> | ||
| 70 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.party.Party" artifactTypeEnumId="AT_ENTITY"/> | ||
| 71 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.party.FindPartyView" artifactTypeEnumId="AT_ENTITY"/> | ||
| 72 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.account.Customer" artifactTypeEnumId="AT_ENTITY"/> | ||
| 73 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="UserAccount" artifactTypeEnumId="AT_ENTITY"/> | ||
| 74 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.ledger.FinancialAccount" artifactTypeEnumId="AT_ENTITY"/> | ||
| 75 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.product.Product" artifactTypeEnumId="AT_ENTITY"/> | ||
| 76 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.invoice.Invoice" artifactTypeEnumId="AT_ENTITY"/> | ||
| 77 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="moqui.server.CommunicationEvent" artifactTypeEnumId="AT_ENTITY"/> | ||
| 78 | <!-- MCP Test Services --> | ||
| 79 | <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="org.moqui.mcp.McpTestServices.*" artifactTypeEnumId="AT_SERVICE"/> | ||
| 80 | <!-- Visit Entity Access --> | ||
| 81 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="moqui.server.Visit" artifactTypeEnumId="AT_ENTITY"/> | ||
| 82 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="create#moqui.server.Visit" artifactTypeEnumId="AT_ENTITY"/> | ||
| 83 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="update#moqui.server.Visit" artifactTypeEnumId="AT_ENTITY"/> | ||
| 84 | <!-- Security Entity Access for permission checking --> | ||
| 85 | <moqui.security.ArtifactGroupMember artifactGroupId="McpSecurityEntities" artifactName="moqui.security.ArtifactGroupMember" artifactTypeEnumId="AT_ENTITY"/> | ||
| 86 | <moqui.security.ArtifactGroupMember artifactGroupId="McpSecurityEntities" artifactName="moqui.security.UserGroupMember" artifactTypeEnumId="AT_ENTITY"/> | ||
| 87 | <moqui.security.ArtifactGroupMember artifactGroupId="McpSecurityEntities" artifactName="moqui.security.ArtifactAuthz" artifactTypeEnumId="AT_ENTITY"/> | ||
| 88 | <!-- Basic Services --> | 31 | <!-- Basic Services --> |
| 89 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#ServerNodeInfo" artifactTypeEnumId="AT_SERVICE"/> | 32 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#ServerNodeInfo" artifactTypeEnumId="AT_SERVICE"/> |
| 90 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#SystemInfo" artifactTypeEnumId="AT_SERVICE"/> | 33 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#SystemInfo" artifactTypeEnumId="AT_SERVICE"/> |
| ... | @@ -94,41 +37,21 @@ | ... | @@ -94,41 +37,21 @@ |
| 94 | <!-- MCP Artifact Authz --> | 37 | <!-- MCP Artifact Authz --> |
| 95 | <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | 38 | <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> |
| 96 | <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | 39 | <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> |
| 97 | <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpScreens" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_VIEW"/> | ||
| 98 | <!-- | ||
| 99 | <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpScreenTransitions" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 100 | <moqui.security.ArtifactAuthz userGroupId="McpUser" artifactGroupId="McpScreenTools" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 101 | --> | ||
| 102 | |||
| 103 | <!-- Give ALL users access to security entities needed for permission checks --> | ||
| 104 | <!-- | ||
| 105 | <moqui.security.ArtifactAuthz userGroupId="ALL_USERS" artifactGroupId="McpSecurityEntities" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 106 | --> | ||
| 107 | 40 | ||
| 108 | <!-- Ensure ADMIN user always has access to security entities needed for permission checks --> | 41 | <!-- Ensure ADMIN user always has access to MCP services --> |
| 109 | <moqui.security.ArtifactAuthz userGroupId="ADMIN" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALWAYS" authzActionEnumId="AUTHZA_ALL"/> | 42 | <moqui.security.ArtifactAuthz userGroupId="ADMIN" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALWAYS" authzActionEnumId="AUTHZA_ALL"/> |
| 110 | <moqui.security.ArtifactAuthz userGroupId="ADMIN" artifactGroupId="McpScreens" authzTypeEnumId="AUTHZT_ALWAYS" authzActionEnumId="AUTHZA_ALL"/> | ||
| 111 | <moqui.security.ArtifactAuthz userGroupId="ADMIN" artifactGroupId="McpScreenTools" authzTypeEnumId="AUTHZT_ALWAYS" authzActionEnumId="AUTHZA_ALL"/> | ||
| 112 | <!-- Explicit permission for screen execution service --> | ||
| 113 | <!-- <moqui.security.ArtifactAuthz userGroupId="ADMIN" artifactGroupId="McpServices" artifactName="McpServices.execute#ScreenAsMcpTool" authzTypeEnumId="AUTHZT_ALWAYS" authzActionEnumId="AUTHZA_ALL"/> --> | ||
| 114 | |||
| 115 | <!-- MCP Business Group Authz --> | ||
| 116 | <moqui.security.ArtifactAuthz userGroupId="MCP_BUSINESS" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 117 | <moqui.security.ArtifactAuthz userGroupId="MCP_BUSINESS" artifactGroupId="McpBusinessServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 118 | <moqui.security.ArtifactAuthz userGroupId="MCP_BUSINESS" artifactGroupId="McpScreens" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 119 | <moqui.security.ArtifactAuthz userGroupId="MCP_BUSINESS" artifactGroupId="McpRestPaths" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 120 | <moqui.security.ArtifactAuthz userGroupId="MCP_BUSINESS" artifactGroupId="McpScreens" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 121 | <moqui.security.ArtifactAuthz userGroupId="MCP_BUSINESS" artifactGroupId="McpScreenTools" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 122 | 43 | ||
| 123 | 44 | ||
| 124 | <!-- MCP User Accounts --> | 45 | <!-- MCP User Accounts --> |
| 125 | <moqui.security.UserAccount userId="MCP_USER" username="mcp-user" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/> | 46 | <moqui.security.UserAccount userId="MCP_USER" username="mcp-user" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/> |
| 126 | <moqui.security.UserAccount userId="MCP_BUSINESS" username="mcp-business" currentPassword="16ac58bbfa332c1c55bd98b53e60720bfa90d394" passwordHashType="SHA"/> | ||
| 127 | 47 | ||
| 128 | <!-- Add MCP users to MCP user groups --> | 48 | <!-- Add MCP users to MCP user groups --> |
| 129 | <moqui.security.UserGroupMember userGroupId="McpUser" userId="MCP_USER" fromDate="2025-01-01 00:00:00.000"/> | 49 | <moqui.security.UserGroupMember userGroupId="McpUser" userId="MCP_USER" fromDate="2025-01-01 00:00:00.000"/> |
| 130 | <moqui.security.UserGroupMember userGroupId="McpUser" userId="JohnSales" fromDate="2025-01-01 00:00:00.000"/> | 50 | <moqui.security.UserGroupMember userGroupId="McpUser" userId="JohnSales" fromDate="2025-01-01 00:00:00.000"/> |
| 131 | <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="MCP_BUSINESS" fromDate="2025-01-01 00:00:00.000"/> | 51 | <moqui.security.UserGroupMember userGroupId="MCP_ALL_ACCESS" userId="JohnSales" fromDate="2025-01-01 00:00:00.000"/> |
| 52 | |||
| 53 | <!-- Permissions for ALL_ACCESS group --> | ||
| 54 | <moqui.security.ArtifactAuthz userGroupId="MCP_ALL_ACCESS" artifactGroupId="McpServices" authzTypeEnumId="AUTHZT_ALLOW" authzActionEnumId="AUTHZA_ALL"/> | ||
| 132 | <!-- ADMIN user doesn't need to be in MCP groups - should have full access by default --> | 55 | <!-- ADMIN user doesn't need to be in MCP groups - should have full access by default --> |
| 133 | 56 | ||
| 134 | <!-- Add existing demo users to MCP business group for focused testing --> | 57 | <!-- Add existing demo users to MCP business group for focused testing --> | ... | ... |
| ... | @@ -137,7 +137,6 @@ | ... | @@ -137,7 +137,6 @@ |
| 137 | def startTime = System.currentTimeMillis() | 137 | def startTime = System.currentTimeMillis() |
| 138 | 138 | ||
| 139 | // Handle stubbed MCP protocol methods by routing to actual Moqui services | 139 | // Handle stubbed MCP protocol methods by routing to actual Moqui services |
| 140 | // Map contains MCP protocol method names to their actual service implementations | ||
| 141 | def protocolMethodMappings = [ | 140 | def protocolMethodMappings = [ |
| 142 | "tools/list": "McpServices.list#Tools", | 141 | "tools/list": "McpServices.list#Tools", |
| 143 | "tools/call": "McpServices.mcp#ToolsCall", | 142 | "tools/call": "McpServices.mcp#ToolsCall", |
| ... | @@ -157,123 +156,58 @@ | ... | @@ -157,123 +156,58 @@ |
| 157 | ec.logger.info("MCP ToolsCall: Routing protocol method ${name} to ${protocolMethodMappings[name]}") | 156 | ec.logger.info("MCP ToolsCall: Routing protocol method ${name} to ${protocolMethodMappings[name]}") |
| 158 | def targetServiceName = protocolMethodMappings[name] | 157 | def targetServiceName = protocolMethodMappings[name] |
| 159 | 158 | ||
| 160 | // Special handling for tools/call to avoid infinite recursion | ||
| 161 | if (name == "tools/call") { | 159 | if (name == "tools/call") { |
| 162 | // Extract the actual tool name and arguments from arguments | ||
| 163 | def actualToolName = arguments?.name | 160 | def actualToolName = arguments?.name |
| 164 | def actualArguments = arguments?.arguments | 161 | def actualArguments = arguments?.arguments |
| 162 | if (!actualToolName) throw new Exception("tools/call requires 'name' parameter in arguments") | ||
| 165 | 163 | ||
| 166 | if (!actualToolName) { | 164 | if (actualArguments instanceof Map) actualArguments.sessionId = sessionId |
| 167 | throw new Exception("tools/call requires 'name' parameter in arguments") | 165 | else actualArguments = [sessionId: sessionId] |
| 168 | } | ||
| 169 | |||
| 170 | // Ensure sessionId is always passed through in arguments | ||
| 171 | if (actualArguments instanceof Map) { | ||
| 172 | actualArguments.sessionId = sessionId | ||
| 173 | } else { | ||
| 174 | actualArguments = [sessionId: sessionId] | ||
| 175 | } | ||
| 176 | |||
| 177 | // Check if this is a screen tool (starts with screen_ or moqui_) - route to screen execution service | ||
| 178 | def screenPath = org.moqui.mcp.McpUtils.getScreenPath(actualToolName) | ||
| 179 | def isLegacyScreen = actualToolName.startsWith("screen_") | ||
| 180 | |||
| 181 | if (screenPath || isLegacyScreen) { | ||
| 182 | ec.logger.info("MCP ToolsCall: Routing screen tool '${actualToolName}' to executeScreenAsMcpTool") | ||
| 183 | 166 | ||
| 167 | // Check if this is a screen tool (starts with moqui_) | ||
| 168 | def screenPath = null | ||
| 184 | def subscreenName = null | 169 | def subscreenName = null |
| 185 | 170 | if (actualToolName.startsWith("moqui_")) { | |
| 186 | if (isLegacyScreen) { | 171 | def decoded = org.moqui.mcp.McpUtils.decodeToolName(actualToolName, ec) |
| 187 | // Decode legacy screen path from tool name | 172 | screenPath = decoded.screenPath |
| 188 | def toolNameSuffix = actualToolName.substring(7) // Remove "screen_" prefix | 173 | subscreenName = decoded.subscreenName |
| 189 | |||
| 190 | // Check if this is a subscreen (contains dot after initial prefix) | ||
| 191 | if (toolNameSuffix.contains('.')) { | ||
| 192 | def lastDotIndex = toolNameSuffix.lastIndexOf('.') | ||
| 193 | def parentPath = toolNameSuffix.substring(0, lastDotIndex) | ||
| 194 | subscreenName = toolNameSuffix.substring(lastDotIndex + 1) | ||
| 195 | screenPath = "component://" + parentPath.replace('_', '/') + ".xml" | ||
| 196 | } else { | ||
| 197 | screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml" | ||
| 198 | } | ||
| 199 | } else { | ||
| 200 | // For moqui_ tools, check existence and fallback to subscreen | ||
| 201 | // Walk down from component root to find the actual screen file and the subscreen path below it | ||
| 202 | def cleanName = actualToolName.substring(6) // Remove moqui_ | ||
| 203 | def parts = cleanName.split('_').toList() | ||
| 204 | def component = parts[0] | ||
| 205 | def currentPath = "component://${component}/screen" | ||
| 206 | def subNameParts = [] | ||
| 207 | |||
| 208 | // Parts start from index 1 (after component) | ||
| 209 | for (int i = 1; i < parts.size(); i++) { | ||
| 210 | def part = parts[i] | ||
| 211 | def nextPath = "${currentPath}/${part}" | ||
| 212 | if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) { | ||
| 213 | currentPath = nextPath | ||
| 214 | // Reset subNameParts when we find a deeper screen file | ||
| 215 | subNameParts = [] | ||
| 216 | } else { | ||
| 217 | subNameParts << part | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | screenPath = currentPath + ".xml" | ||
| 222 | if (subNameParts) subscreenName = subNameParts.join("_") | ||
| 223 | } | 174 | } |
| 224 | 175 | ||
| 176 | if (screenPath) { | ||
| 225 | ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}, subscreen=${subscreenName}") | 177 | ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}, subscreen=${subscreenName}") |
| 226 | |||
| 227 | // Call screen execution service with decoded parameters | ||
| 228 | def screenCallParams = [ | 178 | def screenCallParams = [ |
| 229 | screenPath: screenPath, | 179 | screenPath: screenPath, |
| 230 | parameters: actualArguments, //actualArguments?.arguments ?: [:], | 180 | parameters: actualArguments, |
| 231 | renderMode: actualArguments?.renderMode ?: "html", | 181 | renderMode: actualArguments?.renderMode ?: "html", |
| 232 | sessionId: sessionId | 182 | sessionId: sessionId |
| 233 | ] | 183 | ] |
| 234 | if (subscreenName) { | 184 | if (subscreenName) screenCallParams.subscreenName = subscreenName |
| 235 | screenCallParams.subscreenName = subscreenName | ||
| 236 | } | ||
| 237 | |||
| 238 | 185 | ||
| 239 | ec.logger.info("EXECUTESCREEN ${screenCallParams}") | ||
| 240 | return ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") | 186 | return ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") |
| 241 | .parameters(screenCallParams) | 187 | .parameters(screenCallParams).call() |
| 242 | .call() | ||
| 243 | } else { | 188 | } else { |
| 244 | // For non-screen tools, check if it's another protocol method | ||
| 245 | def actualTargetServiceName = protocolMethodMappings[actualToolName] | 189 | def actualTargetServiceName = protocolMethodMappings[actualToolName] |
| 246 | if (actualTargetServiceName) { | 190 | if (actualTargetServiceName) { |
| 247 | ec.logger.info("MCP ToolsCall: Routing tools/call with name '${actualToolName}' to ${actualTargetServiceName}") | ||
| 248 | return ec.service.sync().name(actualTargetServiceName) | 191 | return ec.service.sync().name(actualTargetServiceName) |
| 249 | .parameters(actualArguments ?: [:]) | 192 | .parameters(actualArguments ?: [:]).call() |
| 250 | .call() | ||
| 251 | } else { | 193 | } else { |
| 194 | // Fallback: check if it's a Moqui service | ||
| 195 | if (ec.service.isServiceDefined(actualToolName)) { | ||
| 196 | def serviceResult = ec.service.sync().name(actualToolName).parameters(actualArguments ?: [:]).call() | ||
| 197 | return [result: [content: [[type: "text", text: new JsonBuilder(serviceResult).toString()]], isError: false]] | ||
| 198 | } | ||
| 252 | throw new Exception("Unknown tool name: ${actualToolName}") | 199 | throw new Exception("Unknown tool name: ${actualToolName}") |
| 253 | } | 200 | } |
| 254 | } | 201 | } |
| 255 | } else { | 202 | } else { |
| 256 | // For other protocol methods, call the target service with provided arguments | 203 | def serviceResult = ec.service.sync().name(targetServiceName).parameters(arguments ?: [:]).call() |
| 257 | ec.logger.info("MCP ToolsCall: ${targetServiceName} ${arguments}") | 204 | def actualRes = serviceResult?.result ?: serviceResult |
| 258 | def serviceResult = ec.service.sync().name(targetServiceName) | 205 | // Ensure standard MCP response format with content array |
| 259 | .parameters(arguments ?: [:]) | 206 | if (actualRes instanceof Map && actualRes.content && actualRes.content instanceof List) { |
| 260 | .call() | 207 | result = actualRes |
| 261 | 208 | } else { | |
| 262 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | 209 | result = [ content: [[type: "text", text: new groovy.json.JsonBuilder(actualRes).toString()]], isError: false ] |
| 263 | |||
| 264 | // Convert result to MCP format | ||
| 265 | def content = [] | ||
| 266 | if (serviceResult?.result) { | ||
| 267 | content << [ | ||
| 268 | type: "text", | ||
| 269 | text: new groovy.json.JsonBuilder(serviceResult.result).toString() | ||
| 270 | ] | ||
| 271 | } | 210 | } |
| 272 | |||
| 273 | result = [ | ||
| 274 | content: content, | ||
| 275 | isError: false | ||
| 276 | ] | ||
| 277 | return | 211 | return |
| 278 | } | 212 | } |
| 279 | } | 213 | } |
| ... | @@ -282,42 +216,21 @@ | ... | @@ -282,42 +216,21 @@ |
| 282 | def screenPath = null | 216 | def screenPath = null |
| 283 | def subscreenName = null | 217 | def subscreenName = null |
| 284 | if (name.startsWith("moqui_")) { | 218 | if (name.startsWith("moqui_")) { |
| 285 | def cleanName = name.substring(6) | 219 | def decoded = org.moqui.mcp.McpUtils.decodeToolName(name, ec) |
| 286 | def parts = cleanName.split('_').toList() | 220 | screenPath = decoded.screenPath |
| 287 | def component = parts[0] | 221 | subscreenName = decoded.subscreenName |
| 288 | def currentPath = "component://${component}/screen" | ||
| 289 | def subNameParts = [] | ||
| 290 | |||
| 291 | for (int i = 1; i < parts.size(); i++) { | ||
| 292 | def part = parts[i] | ||
| 293 | def nextPath = "${currentPath}/${part}" | ||
| 294 | if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) { | ||
| 295 | currentPath = nextPath | ||
| 296 | subNameParts = [] | ||
| 297 | } else { | ||
| 298 | subNameParts << part | ||
| 299 | } | ||
| 300 | } | ||
| 301 | screenPath = currentPath + ".xml" | ||
| 302 | if (subNameParts) subscreenName = subNameParts.join("_") | ||
| 303 | } else { | ||
| 304 | screenPath = org.moqui.mcp.McpUtils.getScreenPath(name) | ||
| 305 | } | 222 | } |
| 306 | 223 | ||
| 307 | if (screenPath) { | 224 | if (screenPath) { |
| 308 | ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}, subscreen: ${subscreenName}") | 225 | ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}, subscreen: ${subscreenName}") |
| 309 | 226 | ||
| 310 | // Now call the screen tool with proper user context | ||
| 311 | def screenParams = arguments ?: [:] | 227 | def screenParams = arguments ?: [:] |
| 312 | // Use requested render mode from arguments, default to text for LLM-friendly output | ||
| 313 | def renderMode = screenParams.remove('renderMode') ?: "html" | 228 | def renderMode = screenParams.remove('renderMode') ?: "html" |
| 314 | def serviceCallParams = [screenPath: screenPath, parameters: screenParams, renderMode: renderMode, sessionId: sessionId] | 229 | def serviceCallParams = [screenPath: screenPath, parameters: screenParams, renderMode: renderMode, sessionId: sessionId] |
| 315 | if (subscreenName) { | 230 | if (subscreenName) serviceCallParams.subscreenName = subscreenName |
| 316 | serviceCallParams.subscreenName = subscreenName | ||
| 317 | } | ||
| 318 | 231 | ||
| 319 | ec.logger.info("SCREENASTOOL ${serviceCallParams}") | 232 | ec.logger.info("SCREENASTOOL ${serviceCallParams}") |
| 320 | serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") | 233 | def serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") |
| 321 | .parameters(serviceCallParams) | 234 | .parameters(serviceCallParams) |
| 322 | .call() | 235 | .call() |
| 323 | 236 | ||
| ... | @@ -434,7 +347,7 @@ | ... | @@ -434,7 +347,7 @@ |
| 434 | </service> | 347 | </service> |
| 435 | 348 | ||
| 436 | <service verb="mcp" noun="ResourcesList" authenticate="false" allow-remote="true" transaction-timeout="60"> | 349 | <service verb="mcp" noun="ResourcesList" authenticate="false" allow-remote="true" transaction-timeout="60"> |
| 437 | <description>Handle MCP resources/list request with Moqui entity discovery</description> | 350 | <description>Handle MCP resources/list request with Moqui entity discovery based on user permissions</description> |
| 438 | <in-parameters> | 351 | <in-parameters> |
| 439 | <parameter name="sessionId"/> | 352 | <parameter name="sessionId"/> |
| 440 | <parameter name="cursor"/> | 353 | <parameter name="cursor"/> |
| ... | @@ -445,72 +358,31 @@ | ... | @@ -445,72 +358,31 @@ |
| 445 | <actions> | 358 | <actions> |
| 446 | <script><![CDATA[ | 359 | <script><![CDATA[ |
| 447 | import org.moqui.context.ExecutionContext | 360 | import org.moqui.context.ExecutionContext |
| 448 | import org.moqui.impl.context.UserFacadeImpl.UserInfo | ||
| 449 | 361 | ||
| 450 | ExecutionContext ec = context.ec | 362 | ExecutionContext ec = context.ec |
| 451 | |||
| 452 | // Build list of available entities as resources | ||
| 453 | def resources = [] | ||
| 454 | |||
| 455 | UserInfo adminUserInfo = null | ||
| 456 | |||
| 457 | // Store original user context before switching to ADMIN | ||
| 458 | def originalUsername = ec.user.username | ||
| 459 | def originalUserId = ec.user.userId | ||
| 460 | def userGroups = ec.user.getUserGroupIdSet().collect { it } | 363 | def userGroups = ec.user.getUserGroupIdSet().collect { it } |
| 461 | |||
| 462 | // Use curated list of commonly used entities instead of discovering all entities | ||
| 463 | def availableResources = [] | 364 | def availableResources = [] |
| 464 | 365 | ||
| 465 | ec.logger.debug("MCP ResourcesList: Starting permissions-based entity discovery ${userGroups}") | 366 | ec.logger.debug("MCP ResourcesList: Discovering entities for user groups: ${userGroups}") |
| 466 | 367 | ||
| 467 | // Get user's accessible entities using Moqui's optimized ArtifactAuthzCheckView | 368 | // Use ArtifactAuthzCheckView to find all entities the user has permission for |
| 468 | def userAccessibleEntities = null as Set<String> | 369 | // This is the "Moqui Way" - rely on the security system to tell us what is accessible |
| 469 | adminUserInfo = null | ||
| 470 | try { | ||
| 471 | adminUserInfo = ec.user.pushUser("ADMIN") | ||
| 472 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") | 370 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") |
| 473 | .condition("userGroupId", userGroups) | 371 | .condition("userGroupId", userGroups) |
| 474 | .condition("artifactTypeEnumId", "AT_ENTITY") | 372 | .condition("artifactTypeEnumId", "AT_ENTITY") |
| 475 | .useCache(true) | 373 | .useCache(true) |
| 476 | .disableAuthz() | 374 | .disableAuthz() |
| 477 | .list() | 375 | .list() |
| 478 | userAccessibleEntities = aacvList.collect { it.artifactName } as Set<String> | ||
| 479 | } finally { | ||
| 480 | if (adminUserInfo != null) { | ||
| 481 | ec.user.popUser() | ||
| 482 | } | ||
| 483 | } | ||
| 484 | |||
| 485 | // Helper function to check if user has permission to an entity | ||
| 486 | def userHasEntityPermission = { entityName -> | ||
| 487 | // Use pre-computed accessible entities set for O(1) lookup | ||
| 488 | return userAccessibleEntities != null && userAccessibleEntities.contains(entityName.toString()) | ||
| 489 | } | ||
| 490 | 376 | ||
| 491 | // Add all permitted entities including ViewEntities for LLM convenience | 377 | for (def aacv in aacvList) { |
| 492 | def allEntityNames = ec.entity.getAllEntityNames() | 378 | def entityName = aacv.artifactName |
| 493 | def allViewNames = [] as Set<String> | 379 | // Basic sanity check to ensure entity is actually defined |
| 494 | 380 | if (ec.entity.isEntityDefined(entityName)) { | |
| 495 | // Get ViewEntities by checking entity definitions for view entities | ||
| 496 | def entityInfoList = ec.entity.getAllEntityInfo(0, true) // includeViewEntities=true | ||
| 497 | for (entityInfo in entityInfoList) { | ||
| 498 | if (entityInfo.isViewEntity) { | ||
| 499 | allViewNames.add(entityInfo.entityName) | ||
| 500 | } | ||
| 501 | } | ||
| 502 | |||
| 503 | // Combine real entities and ViewEntities | ||
| 504 | def allAccessibleEntities = allEntityNames + allViewNames | ||
| 505 | |||
| 506 | for (entityName in allAccessibleEntities) { | ||
| 507 | if (userHasEntityPermission(entityName)) { | ||
| 508 | def description = "Moqui entity: ${entityName}" | 381 | def description = "Moqui entity: ${entityName}" |
| 509 | if (entityName.contains("View")) { | 382 | if (entityName.contains("View")) { |
| 510 | description = "Moqui ViewEntity: ${entityName} (pre-joined data for LLM convenience)" | 383 | description = "Moqui ViewEntity: ${entityName}" |
| 511 | } | 384 | } |
| 512 | 385 | ||
| 513 | ec.logger.debug("MCP ResourcesList: Adding entity: ${entityName}") | ||
| 514 | availableResources << [ | 386 | availableResources << [ |
| 515 | uri: "entity://${entityName}", | 387 | uri: "entity://${entityName}", |
| 516 | name: entityName, | 388 | name: entityName, |
| ... | @@ -1401,22 +1273,54 @@ def startTime = System.currentTimeMillis() | ... | @@ -1401,22 +1273,54 @@ def startTime = System.currentTimeMillis() |
| 1401 | def subscreens = [] | 1273 | def subscreens = [] |
| 1402 | def tools = [] | 1274 | def tools = [] |
| 1403 | def currentPath = path | 1275 | def currentPath = path |
| 1276 | def userGroups = ec.user.getUserGroupIdSet().collect { it } | ||
| 1404 | 1277 | ||
| 1405 | // Logic to find screens | 1278 | // Logic to find screens |
| 1406 | if (!path || path == "/" || path == "root") { | 1279 | if (!path || path == "/" || path == "root") { |
| 1407 | currentPath = "root" | 1280 | currentPath = "root" |
| 1408 | // Return known root apps | 1281 | // Discover top-level applications from ArtifactAuthzCheckView |
| 1409 | // In a real implementation, we'd discover these from components | 1282 | // We look for XML screens that the user has view permission for and are likely app roots |
| 1410 | def roots = ["PopCommerce", "SimpleScreens", "HiveMind", "Mantle"] | 1283 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") |
| 1284 | .condition("userGroupId", userGroups) | ||
| 1285 | .condition("artifactTypeEnumId", "AT_XML_SCREEN") | ||
| 1286 | .useCache(true) | ||
| 1287 | .disableAuthz() | ||
| 1288 | .list() | ||
| 1411 | 1289 | ||
| 1412 | for (root in roots) { | 1290 | def rootScreens = new HashSet() |
| 1413 | def toolName = "moqui_${root}" | 1291 | for (def aacv in aacvList) { |
| 1292 | def name = aacv.artifactName | ||
| 1293 | if (name.startsWith("component://") && name.endsWith(".xml")) { | ||
| 1294 | def parts = name.substring(12).split('/') | ||
| 1295 | if (parts.length >= 3 && parts[1] == "screen") { | ||
| 1296 | def filename = parts[parts.length - 1] | ||
| 1297 | def componentName = parts[0] | ||
| 1298 | // Match component://Name/screen/Name.xml OR component://Name/screen/NameAdmin.xml OR component://Name/screen/NameRoot.xml | ||
| 1299 | if (filename == componentName + ".xml" || filename == componentName + "Admin.xml" || filename == componentName + "Root.xml" || filename == "webroot.xml") { | ||
| 1300 | rootScreens.add(name) | ||
| 1301 | } | ||
| 1302 | } | ||
| 1303 | } | ||
| 1304 | } | ||
| 1305 | |||
| 1306 | for (def screenPath in rootScreens) { | ||
| 1307 | def toolName = McpUtils.getToolName(screenPath) | ||
| 1308 | if (toolName) { | ||
| 1414 | subscreens << [ | 1309 | subscreens << [ |
| 1415 | name: toolName, | 1310 | name: toolName, |
| 1416 | path: toolName, | 1311 | path: toolName, |
| 1417 | description: "Application: ${root}" | 1312 | description: "Application Root: ${screenPath}" |
| 1418 | ] | 1313 | ] |
| 1419 | } | 1314 | } |
| 1315 | } | ||
| 1316 | |||
| 1317 | // Fallback to basic apps if nothing found (to ensure discoverability) | ||
| 1318 | if (subscreens.isEmpty()) { | ||
| 1319 | ["PopCommerce", "SimpleScreens"].each { root -> | ||
| 1320 | def toolName = "moqui_${root}" | ||
| 1321 | subscreens << [ name: toolName, path: toolName, description: "Application: ${root}" ] | ||
| 1322 | } | ||
| 1323 | } | ||
| 1420 | } else { | 1324 | } else { |
| 1421 | // Resolve path | 1325 | // Resolve path |
| 1422 | def screenPath = path.startsWith("moqui_") ? McpUtils.getScreenPath(path) : null | 1326 | def screenPath = path.startsWith("moqui_") ? McpUtils.getScreenPath(path) : null |
| ... | @@ -1437,21 +1341,22 @@ def startTime = System.currentTimeMillis() | ... | @@ -1437,21 +1341,22 @@ def startTime = System.currentTimeMillis() |
| 1437 | } | 1341 | } |
| 1438 | 1342 | ||
| 1439 | if (screenPath) { | 1343 | if (screenPath) { |
| 1344 | def currentScreenPath = screenPath | ||
| 1345 | if (!currentScreenPath.endsWith(".xml")) currentScreenPath += ".xml" | ||
| 1440 | // Check if screen exists, if not try to resolve as subscreen | 1346 | // Check if screen exists, if not try to resolve as subscreen |
| 1441 | if (!ec.resource.getLocationReference(screenPath).getExists()) { | 1347 | if (!ec.resource.getLocationReference(currentScreenPath).getExists()) { |
| 1442 | def lastSlash = screenPath.lastIndexOf('/') | 1348 | def lastSlash = currentScreenPath.lastIndexOf('/') |
| 1443 | if (lastSlash > 0) { | 1349 | if (lastSlash > 0) { |
| 1444 | def parentPath = screenPath.substring(0, lastSlash) + ".xml" | 1350 | def parentPath = currentScreenPath.substring(0, lastSlash) + ".xml" |
| 1445 | def subscreenName = screenPath.substring(lastSlash + 1).replace('.xml', '') | 1351 | def subName = currentScreenPath.substring(lastSlash + 1).replace('.xml', '') |
| 1446 | 1352 | ||
| 1447 | if (ec.resource.getLocationReference(parentPath).getExists()) { | 1353 | if (ec.resource.getLocationReference(parentPath).getExists()) { |
| 1448 | // Found parent, now find the subscreen location | ||
| 1449 | try { | 1354 | try { |
| 1450 | def parentDef = ec.screen.getScreenDefinition(parentPath) | 1355 | def parentDef = ec.screen.getScreenDefinition(parentPath) |
| 1451 | def subscreenItem = parentDef?.getSubscreensItem(subscreenName) | 1356 | def subscreenItem = parentDef?.getSubscreensItem(subName) |
| 1452 | if (subscreenItem && subscreenItem.getLocation()) { | 1357 | if (subscreenItem && subscreenItem.getLocation()) { |
| 1453 | ec.logger.info("Redirecting browse from ${screenPath} to ${subscreenItem.getLocation()}") | 1358 | ec.logger.info("Redirecting browse from ${currentScreenPath} to ${subscreenItem.getLocation()}") |
| 1454 | screenPath = subscreenItem.getLocation() | 1359 | currentScreenPath = subscreenItem.getLocation() |
| 1455 | } | 1360 | } |
| 1456 | } catch (Exception e) { | 1361 | } catch (Exception e) { |
| 1457 | ec.logger.warn("Error resolving subscreen location: ${e.message}") | 1362 | ec.logger.warn("Error resolving subscreen location: ${e.message}") |
| ... | @@ -1461,10 +1366,7 @@ def startTime = System.currentTimeMillis() | ... | @@ -1461,10 +1366,7 @@ def startTime = System.currentTimeMillis() |
| 1461 | } | 1366 | } |
| 1462 | 1367 | ||
| 1463 | try { | 1368 | try { |
| 1464 | // First, add the current screen itself as a tool if it's executable | 1369 | def currentToolName = baseToolName ?: McpUtils.getToolName(currentScreenPath) |
| 1465 | // Use baseToolName if available to preserve context (e.g. moqui_PopCommerce_...) | ||
| 1466 | // even if the implementation is in another component (e.g. SimpleScreens) | ||
| 1467 | def currentToolName = baseToolName ?: McpUtils.getToolName(screenPath) | ||
| 1468 | tools << [ | 1370 | tools << [ |
| 1469 | name: currentToolName, | 1371 | name: currentToolName, |
| 1470 | description: "Execute screen: ${currentToolName}", | 1372 | description: "Execute screen: ${currentToolName}", |
| ... | @@ -1472,25 +1374,17 @@ def startTime = System.currentTimeMillis() | ... | @@ -1472,25 +1374,17 @@ def startTime = System.currentTimeMillis() |
| 1472 | ] | 1374 | ] |
| 1473 | 1375 | ||
| 1474 | // Then look for subscreens | 1376 | // Then look for subscreens |
| 1475 | // Use getScreenInfoList with depth/mode? to ensure implicit subscreens are loaded | 1377 | def screenDef = null |
| 1476 | // The original list#Tools used getScreenInfoList(path, 1) | ||
| 1477 | def screenInfoList = null | ||
| 1478 | try { | 1378 | try { |
| 1479 | screenInfoList = ec.screen.getScreenInfoList(screenPath, 1) | 1379 | screenDef = ec.screen.getScreenDefinition(currentScreenPath) |
| 1480 | } catch (Exception e) { | 1380 | } catch (Exception e) { |
| 1481 | ec.logger.debug("getScreenInfoList failed, trying getScreenInfo: ${e.message}") | 1381 | ec.logger.warn("Error getting screen definition for ${currentScreenPath}: ${e.message}") |
| 1482 | } | 1382 | } |
| 1483 | 1383 | ||
| 1484 | def screenInfo = screenInfoList ? screenInfoList.first() : ec.screen.getScreenInfo(screenPath) | 1384 | if (screenDef) { |
| 1485 | 1385 | def subItems = screenDef.getSubscreensItemsSorted() | |
| 1486 | if (screenInfo?.subscreenInfoByName) { | 1386 | for (subItem in subItems) { |
| 1487 | for (entry in screenInfo.subscreenInfoByName) { | 1387 | def subName = subItem.getName() |
| 1488 | def subName = entry.key | ||
| 1489 | def subInfo = entry.value | ||
| 1490 | |||
| 1491 | // Construct sub-tool name by extending the parent path | ||
| 1492 | // This assumes subscreens are structurally nested in the tool name | ||
| 1493 | // e.g. moqui_PopCommerce_Admin -> moqui_PopCommerce_Admin_Catalog | ||
| 1494 | def parentToolName = baseToolName ?: McpUtils.getToolName(screenPath) | 1388 | def parentToolName = baseToolName ?: McpUtils.getToolName(screenPath) |
| 1495 | def subToolName = parentToolName + "_" + subName | 1389 | def subToolName = parentToolName + "_" + subName |
| 1496 | 1390 | ||
| ... | @@ -1561,7 +1455,7 @@ def startTime = System.currentTimeMillis() | ... | @@ -1561,7 +1455,7 @@ def startTime = System.currentTimeMillis() |
| 1561 | </service> | 1455 | </service> |
| 1562 | 1456 | ||
| 1563 | <service verb="mcp" noun="GetScreenDetails" authenticate="false" allow-remote="true" transaction-timeout="30"> | 1457 | <service verb="mcp" noun="GetScreenDetails" authenticate="false" allow-remote="true" transaction-timeout="30"> |
| 1564 | <description>Get detailed schema and usage info for a specific screen tool.</description> | 1458 | <description>Get detailed schema and usage info for a specific screen tool, including inferred parameters from entities.</description> |
| 1565 | <in-parameters> | 1459 | <in-parameters> |
| 1566 | <parameter name="name" required="true"><description>Tool name (e.g. moqui_PopCommerce_...)</description></parameter> | 1460 | <parameter name="name" required="true"><description>Tool name (e.g. moqui_PopCommerce_...)</description></parameter> |
| 1567 | <parameter name="sessionId"/> | 1461 | <parameter name="sessionId"/> |
| ... | @@ -1575,39 +1469,67 @@ def startTime = System.currentTimeMillis() | ... | @@ -1575,39 +1469,67 @@ def startTime = System.currentTimeMillis() |
| 1575 | import org.moqui.mcp.McpUtils | 1469 | import org.moqui.mcp.McpUtils |
| 1576 | 1470 | ||
| 1577 | ExecutionContext ec = context.ec | 1471 | ExecutionContext ec = context.ec |
| 1578 | def screenPath = McpUtils.getScreenPath(name) | 1472 | def decoded = McpUtils.decodeToolName(name, ec) |
| 1473 | def screenPath = decoded.screenPath | ||
| 1579 | ec.logger.info("GetScreenDetails: name=${name} -> screenPath=${screenPath}") | 1474 | ec.logger.info("GetScreenDetails: name=${name} -> screenPath=${screenPath}") |
| 1580 | def toolDef = null | 1475 | def toolDef = null |
| 1581 | 1476 | ||
| 1582 | if (screenPath) { | 1477 | if (screenPath) { |
| 1583 | try { | 1478 | try { |
| 1584 | def parameters = [:] | 1479 | def properties = [:] |
| 1585 | // Use getScreenDefinition for stable access to parameters | 1480 | def required = [] |
| 1586 | def screenDef = ec.screen.getScreenDefinition(screenPath) | 1481 | def screenDef = null |
| 1482 | try { | ||
| 1483 | screenDef = ec.screen.getScreenDefinition(screenPath) | ||
| 1484 | } catch (Exception e) { | ||
| 1485 | ec.logger.warn("Error getting screen definition for ${screenPath}: ${e.message}") | ||
| 1486 | } | ||
| 1587 | 1487 | ||
| 1588 | if (screenDef && screenDef.screenNode) { | 1488 | if (screenDef && screenDef.screenNode) { |
| 1589 | // Extract parameters from XML node | 1489 | // Helper to convert Moqui type to JSON Schema type |
| 1590 | def parameterNodes = screenDef.screenNode.children("parameter") | 1490 | def getJsonType = { moquiType -> |
| 1591 | for (node in parameterNodes) { | 1491 | def typeRes = ec.service.sync().name("McpServices.convert#MoquiTypeToJsonSchemaType") |
| 1492 | .parameter("moquiType", moquiType).call() | ||
| 1493 | return typeRes?.jsonSchemaType ?: "string" | ||
| 1494 | } | ||
| 1495 | |||
| 1496 | // 1. Extract explicit parameters from screen XML | ||
| 1497 | screenDef.screenNode.children("parameter").each { node -> | ||
| 1592 | def paramName = node.attribute("name") | 1498 | def paramName = node.attribute("name") |
| 1593 | parameters[paramName] = [ | 1499 | properties[paramName] = [ |
| 1594 | type: "string", | 1500 | type: "string", |
| 1595 | description: "Screen Parameter" | 1501 | description: "Screen Parameter" |
| 1596 | ] | 1502 | ] |
| 1503 | if (node.attribute("required") == "true") required << paramName | ||
| 1597 | } | 1504 | } |
| 1598 | 1505 | ||
| 1599 | // Also extract transition parameters if possible | 1506 | // 2. Extract transition parameters (actions/links) |
| 1600 | def transitionNodes = screenDef.screenNode.children("transition") | 1507 | screenDef.screenNode.children("transition").each { node -> |
| 1601 | for (node in transitionNodes) { | 1508 | node.children("parameter").each { tp -> |
| 1602 | // Transition parameters | ||
| 1603 | def tParams = node.children("parameter") | ||
| 1604 | for (tp in tParams) { | ||
| 1605 | def tpName = tp.attribute("name") | 1509 | def tpName = tp.attribute("name") |
| 1606 | if (!parameters[tpName]) { | 1510 | if (!properties[tpName]) { |
| 1607 | parameters[tpName] = [ | 1511 | properties[tpName] = [ |
| 1608 | type: "string", | 1512 | type: "string", |
| 1609 | description: "Transition Parameter for ${node.attribute('name')}" | 1513 | description: "Transition Parameter for ${node.attribute('name')}" |
| 1610 | ] | 1514 | ] |
| 1515 | if (tp.attribute("required") == "true") required << tpName | ||
| 1516 | } | ||
| 1517 | } | ||
| 1518 | } | ||
| 1519 | |||
| 1520 | // 3. Infer parameters from form-list or form-single if they reference an entity | ||
| 1521 | screenDef.screenNode.depthFirst().findAll { it.name() == "form-single" || it.name() == "form-list" }.each { formNode -> | ||
| 1522 | def entityName = formNode.attribute("entity-name") | ||
| 1523 | if (entityName && ec.entity.isEntityDefined(entityName)) { | ||
| 1524 | def entityDef = ec.entity.getEntityDefinition(entityName) | ||
| 1525 | entityDef.getAllFieldNames().each { fieldName -> | ||
| 1526 | if (!properties[fieldName]) { | ||
| 1527 | def fieldInfo = entityDef.getFieldNode(fieldName) | ||
| 1528 | properties[fieldName] = [ | ||
| 1529 | type: getJsonType(fieldInfo.attribute("type")), | ||
| 1530 | description: "Inferred from entity ${entityName}" | ||
| 1531 | ] | ||
| 1532 | } | ||
| 1611 | } | 1533 | } |
| 1612 | } | 1534 | } |
| 1613 | } | 1535 | } |
| ... | @@ -1618,7 +1540,8 @@ def startTime = System.currentTimeMillis() | ... | @@ -1618,7 +1540,8 @@ def startTime = System.currentTimeMillis() |
| 1618 | description: "Full details for ${name} (${screenPath})", | 1540 | description: "Full details for ${name} (${screenPath})", |
| 1619 | inputSchema: [ | 1541 | inputSchema: [ |
| 1620 | type: "object", | 1542 | type: "object", |
| 1621 | properties: parameters | 1543 | properties: properties, |
| 1544 | required: required | ||
| 1622 | ] | 1545 | ] |
| 1623 | ] | 1546 | ] |
| 1624 | } catch (Exception e) { | 1547 | } catch (Exception e) { | ... | ... |
| ... | @@ -582,12 +582,12 @@ try { | ... | @@ -582,12 +582,12 @@ try { |
| 582 | 582 | ||
| 583 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 | 583 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 |
| 584 | // Client MUST include Accept header listing both application/json and text/event-stream | 584 | // Client MUST include Accept header listing both application/json and text/event-stream |
| 585 | if (!acceptHeader || !(acceptHeader.contains("application/json") || acceptHeader.contains("text/event-stream"))) { | 585 | if (!acceptHeader || !(acceptHeader.contains("application/json") && acceptHeader.contains("text/event-stream"))) { |
| 586 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | 586 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) |
| 587 | response.setContentType("application/json") | 587 | response.setContentType("application/json") |
| 588 | response.writer.write(JsonOutput.toJson([ | 588 | response.writer.write(JsonOutput.toJson([ |
| 589 | jsonrpc: "2.0", | 589 | jsonrpc: "2.0", |
| 590 | error: [code: -32600, message: "Accept header must include application/json and/or text/event-stream per MCP 2025-11-25 spec"], | 590 | error: [code: -32600, message: "Accept header must include both application/json and text/event-stream per MCP 2025-11-25 spec"], |
| 591 | id: null | 591 | id: null |
| 592 | ])) | 592 | ])) |
| 593 | return | 593 | return | ... | ... |
| 1 | /* | ||
| 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, 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 | ||
| 8 | * 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 | * <http://creativecommons.org/publicdomain/zero/1.0/>. | ||
| 13 | */ | ||
| 1 | package org.moqui.mcp | 14 | package org.moqui.mcp |
| 2 | 15 | ||
| 16 | import org.moqui.context.ExecutionContext | ||
| 3 | import org.moqui.util.MNode | 17 | import org.moqui.util.MNode |
| 4 | 18 | ||
| 5 | class McpUtils { | 19 | class McpUtils { |
| 6 | /** | 20 | /** |
| 7 | * Convert a Moqui screen path to an MCP tool name. | 21 | * Convert a Moqui screen path to an MCP tool name. |
| 8 | * Preserves case to ensure reversibility. | ||
| 9 | * Format: moqui_<Component>_<Path_Parts> | 22 | * Format: moqui_<Component>_<Path_Parts> |
| 10 | * Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog | 23 | * Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog |
| 11 | */ | 24 | */ |
| 12 | static String getToolName(String screenPath) { | 25 | static String getToolName(String screenPath) { |
| 13 | if (!screenPath) return null | 26 | if (!screenPath) return null |
| 14 | 27 | ||
| 15 | // Strip component:// prefix and .xml suffix | ||
| 16 | String cleanPath = screenPath | 28 | String cleanPath = screenPath |
| 17 | if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12) | 29 | if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12) |
| 18 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) | 30 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) |
| 19 | 31 | ||
| 20 | List<String> parts = cleanPath.split('/').toList() | 32 | List<String> parts = cleanPath.split('/').toList() |
| 21 | |||
| 22 | // Remove 'screen' if it's the second part (standard structure: component/screen/...) | ||
| 23 | if (parts.size() > 1 && parts[1] == "screen") { | 33 | if (parts.size() > 1 && parts[1] == "screen") { |
| 24 | parts.remove(1) | 34 | parts.remove(1) |
| 25 | } | 35 | } |
| 26 | 36 | ||
| 27 | // Join with underscores and prefix | ||
| 28 | return "moqui_" + parts.join('_') | 37 | return "moqui_" + parts.join('_') |
| 29 | } | 38 | } |
| 30 | 39 | ||
| ... | @@ -37,20 +46,46 @@ class McpUtils { | ... | @@ -37,20 +46,46 @@ class McpUtils { |
| 37 | 46 | ||
| 38 | String cleanName = toolName.substring(6) // Remove moqui_ | 47 | String cleanName = toolName.substring(6) // Remove moqui_ |
| 39 | List<String> parts = cleanName.split('_').toList() | 48 | List<String> parts = cleanName.split('_').toList() |
| 40 | |||
| 41 | if (parts.size() < 1) return null | 49 | if (parts.size() < 1) return null |
| 42 | 50 | ||
| 43 | String component = parts[0] | 51 | String component = parts[0] |
| 44 | |||
| 45 | // If there's only one part (e.g. moqui_MyComponent), it might be a root or invalid | ||
| 46 | // But usually we expect at least component and screen | ||
| 47 | if (parts.size() == 1) { | 52 | if (parts.size() == 1) { |
| 48 | // Fallback for component roots? unlikely to be a valid screen path without 'screen' dir | ||
| 49 | return "component://${component}/screen/${component}.xml" | 53 | return "component://${component}/screen/${component}.xml" |
| 50 | } | 54 | } |
| 51 | 55 | ||
| 52 | // Re-insert 'screen' directory which is standard | ||
| 53 | String path = parts.subList(1, parts.size()).join('/') | 56 | String path = parts.subList(1, parts.size()).join('/') |
| 54 | return "component://${component}/screen/${path}.xml" | 57 | return "component://${component}/screen/${path}.xml" |
| 55 | } | 58 | } |
| 59 | |||
| 60 | /** | ||
| 61 | * Decodes a tool name into a screen path and potential subscreen path by walking the component structure. | ||
| 62 | * This handles cases where a single XML file contains multiple nested subscreens. | ||
| 63 | */ | ||
| 64 | static Map decodeToolName(String toolName, ExecutionContext ec) { | ||
| 65 | if (!toolName || !toolName.startsWith("moqui_")) return [:] | ||
| 66 | |||
| 67 | String cleanName = toolName.substring(6) // Remove moqui_ | ||
| 68 | List<String> parts = cleanName.split('_').toList() | ||
| 69 | String component = parts[0] | ||
| 70 | |||
| 71 | String currentPath = "component://${component}/screen" | ||
| 72 | List<String> subNameParts = [] | ||
| 73 | |||
| 74 | // Walk down the parts to find where the XML file ends and subscreens begin | ||
| 75 | for (int i = 1; i < parts.size(); i++) { | ||
| 76 | String part = parts[i] | ||
| 77 | String nextPath = "${currentPath}/${part}" | ||
| 78 | if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) { | ||
| 79 | currentPath = nextPath | ||
| 80 | subNameParts = [] // Reset subscreens if we found a deeper file | ||
| 81 | } else { | ||
| 82 | subNameParts << part | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | return [ | ||
| 87 | screenPath: currentPath + ".xml", | ||
| 88 | subscreenName: subNameParts ? subNameParts.join("_") : null | ||
| 89 | ] | ||
| 90 | } | ||
| 56 | } | 91 | } | ... | ... |
-
Please register or sign in to post a comment