1bb29d0c by Ean Schuessler

feat(mcp): Implement recursive screen discovery for MCP tools

1 parent 1f2291ad
...@@ -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 }
......