5745c06b by Ean Schuessler

Implement ergonomic MCP tool improvements: tiered discovery (Browse, Search, Det…

…ails), semantic naming, and McpUtils refactoring.
1 parent 61da89b9
...@@ -147,7 +147,10 @@ ...@@ -147,7 +147,10 @@
147 "resources/unsubscribe": "McpServices.mcp#ResourcesUnsubscribe", 147 "resources/unsubscribe": "McpServices.mcp#ResourcesUnsubscribe",
148 "prompts/list": "McpServices.mcp#PromptsList", 148 "prompts/list": "McpServices.mcp#PromptsList",
149 "prompts/get": "McpServices.mcp#PromptsGet", 149 "prompts/get": "McpServices.mcp#PromptsGet",
150 "ping": "McpServices.mcp#Ping" 150 "ping": "McpServices.mcp#Ping",
151 "moqui_browse_screens": "McpServices.mcp#BrowseScreens",
152 "moqui_search_screens": "McpServices.mcp#SearchScreens",
153 "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails"
151 ] 154 ]
152 155
153 if (protocolMethodMappings.containsKey(name)) { 156 if (protocolMethodMappings.containsKey(name)) {
...@@ -171,31 +174,46 @@ ...@@ -171,31 +174,46 @@
171 actualArguments = [sessionId: sessionId] 174 actualArguments = [sessionId: sessionId]
172 } 175 }
173 176
174 // Check if this is a screen tool (starts with screen_) - route to screen execution service 177 // Check if this is a screen tool (starts with screen_ or moqui_) - route to screen execution service
175 if (actualToolName.startsWith("screen_")) { 178 def screenPath = org.moqui.mcp.McpUtils.getScreenPath(actualToolName)
179 def isLegacyScreen = actualToolName.startsWith("screen_")
180
181 if (screenPath || isLegacyScreen) {
176 ec.logger.info("MCP ToolsCall: Routing screen tool '${actualToolName}' to executeScreenAsMcpTool") 182 ec.logger.info("MCP ToolsCall: Routing screen tool '${actualToolName}' to executeScreenAsMcpTool")
177 // Decode screen path from tool name for screen execution
178 def toolNameSuffix = actualToolName.substring(7) // Remove "screen_" prefix
179 183
180 def screenPath
181 def subscreenName = null 184 def subscreenName = null
182 185
183 // Check if this is a subscreen (contains dot after initial prefix) 186 if (isLegacyScreen) {
184 if (toolNameSuffix.contains('.')) { 187 // Decode legacy screen path from tool name
185 // Split on dot to separate parent screen path from subscreen name 188 def toolNameSuffix = actualToolName.substring(7) // Remove "screen_" prefix
186 def lastDotIndex = toolNameSuffix.lastIndexOf('.')
187 def parentPath = toolNameSuffix.substring(0, lastDotIndex)
188 subscreenName = toolNameSuffix.substring(lastDotIndex + 1)
189 189
190 // Restore parent path: _ -> /, prepend component://, append .xml 190 // Check if this is a subscreen (contains dot after initial prefix)
191 screenPath = "component://" + parentPath.replace('_', '/') + ".xml" 191 if (toolNameSuffix.contains('.')) {
192 ec.logger.info("MCP ToolsCall: Decoded screen tool - parent=${screenPath}, subscreen=${subscreenName}") 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 }
193 } else { 199 } else {
194 // Regular screen path: _ -> /, prepend component://, append .xml 200 // For moqui_ tools, check existence and fallback to subscreen
195 screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml" 201 if (!ec.resource.getLocationReference(screenPath).getExists()) {
196 ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}") 202 def lastSlash = screenPath.lastIndexOf('/')
203 if (lastSlash > 0) {
204 def parentPath = screenPath.substring(0, lastSlash) + ".xml"
205 def possibleSubscreen = screenPath.substring(lastSlash + 1).replace('.xml', '')
206
207 if (ec.resource.getLocationReference(parentPath).getExists()) {
208 screenPath = parentPath
209 subscreenName = possibleSubscreen
210 }
211 }
212 }
197 } 213 }
198 214
215 ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}, subscreen=${subscreenName}")
216
199 // Call screen execution service with decoded parameters 217 // Call screen execution service with decoded parameters
200 def screenCallParams = [ 218 def screenCallParams = [
201 screenPath: screenPath, 219 screenPath: screenPath,
...@@ -250,30 +268,35 @@ ...@@ -250,30 +268,35 @@
250 } 268 }
251 } 269 }
252 270
253 // Check if this is a screen-based tool or a service-based tool 271 // Check if this is a screen-based tool using McpUtils
254 def isScreenTool = name.startsWith("screen_") 272 def screenPath = org.moqui.mcp.McpUtils.getScreenPath(name)
255 273
256 if (isScreenTool) { 274 if (screenPath) {
257 // Decode screen path from tool name (Clean Encoding with subscreen support) 275 ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}")
258 def toolNameSuffix = name.substring(7) // Remove "screen_" prefix
259 276
260 def screenPath
261 def subscreenName = null 277 def subscreenName = null
262 278
263 // Check if this is a subscreen (contains dot after initial prefix) 279 // Verify if the screen file exists
264 if (toolNameSuffix.contains('.')) { 280 // If not, it might be a subscreen (e.g. FindProduct inside Product.xml)
265 // Split on dot to separate parent screen path from subscreen name 281 // Use getExists() instead of exists() as it is a property accessor
266 def lastDotIndex = toolNameSuffix.lastIndexOf('.') 282 if (!ec.resource.getLocationReference(screenPath).getExists()) {
267 def parentPath = toolNameSuffix.substring(0, lastDotIndex) 283 ec.logger.info("Screen path ${screenPath} does not exist, checking for subscreen parent")
268 subscreenName = toolNameSuffix.substring(lastDotIndex + 1) 284 // Try to find parent screen file
269 285 def lastSlash = screenPath.lastIndexOf('/')
270 // Restore parent path: _ -> /, prepend component://, append .xml 286 if (lastSlash > 0) {
271 screenPath = "component://" + parentPath.replace('_', '/') + ".xml" 287 def parentPath = screenPath.substring(0, lastSlash) + ".xml"
272 ec.logger.info("Decoded subscreen path for tool ${name}: parent=${screenPath}, subscreen=${subscreenName}") 288 // The subscreen name is the part after the slash, without .xml
273 } else { 289 // But wait, screenPath from McpUtils ends in .xml
274 // Regular screen path: _ -> /, prepend component://, append .xml 290 // screenPath: .../Catalog/Product/FindProduct.xml
275 screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml" 291 // subscreenName: FindProduct
276 ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}") 292 def possibleSubscreen = screenPath.substring(lastSlash + 1).replace('.xml', '')
293
294 if (ec.resource.getLocationReference(parentPath).getExists()) {
295 screenPath = parentPath
296 subscreenName = possibleSubscreen
297 ec.logger.info("Found parent screen: ${screenPath}, subscreen: ${subscreenName}")
298 }
299 }
277 } 300 }
278 301
279 // Now call the screen tool with proper user context 302 // Now call the screen tool with proper user context
...@@ -290,7 +313,7 @@ ...@@ -290,7 +313,7 @@
290 .parameters(serviceCallParams) 313 .parameters(serviceCallParams)
291 .call() 314 .call()
292 315
293 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 316 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
294 317
295 // Convert result to MCP format 318 // Convert result to MCP format
296 def content = [] 319 def content = []
...@@ -313,8 +336,6 @@ ...@@ -313,8 +336,6 @@
313 } 336 }
314 } 337 }
315 338
316 // Extract content from ScreenAsMcpTool result, don't nest it
317 // result = serviceResult?.result ?: [
318 result = [ 339 result = [
319 content: content, 340 content: content,
320 isError: false 341 isError: false
...@@ -511,120 +532,75 @@ ...@@ -511,120 +532,75 @@
511 import groovy.json.JsonBuilder 532 import groovy.json.JsonBuilder
512 533
513 ExecutionContext ec = context.ec 534 ExecutionContext ec = context.ec
535 def startTime = System.currentTimeMillis()
514 536
515 // Parse entity URI (format: entity://EntityName) 537 // Parse entity URI (format: entity://EntityName)
516 if (!uri.startsWith("entity://")) { 538 if (!uri.startsWith("entity://")) {
517 throw new Exception("Invalid resource URI: ${uri}") 539 throw new Exception("Invalid resource URI: ${uri}")
518 } 540 }
519 541
520 def entityName = uri.substring(9) // Remove "entity://" prefix 542 def entityName = uri.substring(9)
521 543
522 // Validate entity exists
523 if (!ec.entity.isEntityDefined(entityName)) { 544 if (!ec.entity.isEntityDefined(entityName)) {
524 throw new Exception("Entity not found: ${entityName}") 545 throw new Exception("Entity not found: ${entityName}")
525 } 546 }
526 547
527 // Permission checking is handled by Moqui's artifact authorization system through artifact groups 548 try {
528 549 def entityDef = null
529 try { 550 try {
530 // Try to get entity definition - handle both real entities and view entities 551 def entityInfoList = ec.entity.getAllEntityInfo(-1, true)
531 def entityDef = null 552 entityDef = entityInfoList.find { it.entityName == entityName }
532 try { 553 } catch (Exception e) {
533 // First try getAllEntityInfo for detailed info 554 ec.logger.debug("Error getting detailed entity info: ${e.message}")
534 def entityInfoList = ec.entity.getAllEntityInfo(-1, true) // all entities, include view entities 555 }
535 entityDef = entityInfoList.find { it.entityName == entityName } 556
536 557 if (!entityDef) {
537 if (!entityDef) { 558 entityDef = [
538 // If not found in detailed list, try basic entity check 559 entityName: entityName,
539 if (ec.entity.isEntityDefined(entityName)) { 560 packageName: entityName.contains('.') ? entityName.split('\\.')[0] : "",
540 // Create minimal entity definition for basic query 561 description: "Entity: ${entityName}",
541 entityDef = [ 562 isViewEntity: entityName.contains('View'),
542 entityName: entityName, 563 allFieldInfoList: []
543 packageName: entityName.split('\\.')[0], 564 ]
544 description: "Entity: ${entityName}", 565 }
545 isViewEntity: entityName.contains('View'), 566
546 allFieldInfoList: [] 567 // Query entity data
547 ] 568 def entityList = ec.entity.find(entityName).limit(100).list()
548 } 569
549 } 570 // Format response
550 571 def responseMap = [
551 ec.logger.warn("ResourcesRead: Error getting entity info for ${entityName}: ${e.message}") 572 entityName: entityName,
552 // Fallback: try basic entity check 573 description: entityDef.description,
553 if (ec.entity.isEntityDefined(entityName)) { 574 packageName: entityDef.packageName,
554 entityDef = [ 575 recordCount: entityList.size(),
555 entityName: entityName, 576 data: entityList
556 packageName: entityName.split('\\.')[0], 577 ]
557 description: "Entity: ${entityName}", 578
558 isViewEntity: entityName.contains('View'), 579 def jsonOutput = new JsonBuilder(responseMap).toString()
559 allFieldInfoList: [] 580
560 ] 581 // Size protection
561 } 582 def maxResponseSize = 1024 * 1024 // 1MB
562 583 if (jsonOutput.length() > maxResponseSize) {
563 if (!entityDef) { 584 def truncatedList = entityList.take(10)
564 throw new Exception("Entity not found: ${entityName}") 585 responseMap.data = truncatedList
565 } 586 responseMap.truncated = true
566 587 responseMap.message = "Truncated to 10 records due to size."
567 ec.logger.info("ResourcesRead: Found entity ${entityName}, isViewEntity=${entityDef.isViewEntity}") 588 jsonOutput = new JsonBuilder(responseMap).toString()
568 589 }
569 // Query entity data (limited to prevent large responses) 590
570 def entityList = ec.entity.find(entityName) 591 result = [
571 .limit(100) 592 content: [[
572 .list() 593 uri: uri,
573 594 mimeType: "application/json",
574 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 595 text: jsonOutput
575 596 ]],
576 // SIZE PROTECTION: Check response size before returning 597 isError: false
577 def jsonOutput = new JsonBuilder([ 598 ]
578 entityName: entityName, 599
579 description: entityDef.description ?: "", 600 } catch (Exception e) {
580 packageName: entityDef.packageName, 601 ec.logger.error("Error reading resource ${uri}", e)
581 recordCount: entityList.size(), 602 result = [isError: true, content: [[type:"text", text: e.message]]]
582 fields: fieldInfo, 603 }
583 data: entityList
584 ]).toString()
585
586 def maxResponseSize = 1024 * 1024 // 1MB limit
587 if (jsonOutput.length() > maxResponseSize) {
588 ec.logger.warn("ResourcesRead: Response too large for ${entityName}: ${jsonOutput.length()} bytes (limit: ${maxResponseSize} bytes)")
589
590 // Create truncated response with fewer records
591 def truncatedList = entityList.take(10) // Keep only first 10 records
592 def truncatedOutput = new JsonBuilder([
593 entityName: entityName,
594 description: entityDef.description ?: "",
595 packageName: entityDef.packageName,
596 recordCount: entityList.size(),
597 fields: fieldInfo,
598 data: truncatedList,
599 truncated: true,
600 originalSize: entityList.size(),
601 truncatedSize: truncatedList.size(),
602 message: "Response truncated due to size limits. Original data has ${entityList.size()} records, showing first ${truncatedList.size()}."
603 ]).toString()
604
605 contents = [
606 [
607 uri: uri,
608 mimeType: "application/json",
609 text: truncatedOutput
610 ]
611 ]
612 } else {
613 // Normal response
614 contents = [
615 [
616 uri: uri,
617 mimeType: "application/json",
618 text: jsonOutput
619 ]
620 ]
621 }
622
623 } catch (Exception e) {
624 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
625 ec.logger.warn("Error reading resource ${uri}: ${e.message}")
626 result = [error: "Error reading resource ${uri}: ${e.message}"]
627 }
628 ]]></script> 604 ]]></script>
629 </actions> 605 </actions>
630 </service> 606 </service>
...@@ -1376,11 +1352,11 @@ def startTime = System.currentTimeMillis() ...@@ -1376,11 +1352,11 @@ def startTime = System.currentTimeMillis()
1376 </actions> 1352 </actions>
1377 </service> 1353 </service>
1378 1354
1379 <service verb="list" noun="Tools" authenticate="false" allow-remote="true" transaction-timeout="60"> 1355 <service verb="mcp" noun="BrowseScreens" authenticate="false" allow-remote="true" transaction-timeout="30">
1380 <description>Compact tool discovery using ArtifactAuthzCheckView with clean recursion</description> 1356 <description>Browse Moqui screens hierarchically to discover functionality.</description>
1381 <in-parameters> 1357 <in-parameters>
1358 <parameter name="path" required="false"><description>Screen path or tool name to browse. Leave empty for root apps.</description></parameter>
1382 <parameter name="sessionId"/> 1359 <parameter name="sessionId"/>
1383 <parameter name="cursor"/>
1384 </in-parameters> 1360 </in-parameters>
1385 <out-parameters> 1361 <out-parameters>
1386 <parameter name="result" type="Map"/> 1362 <parameter name="result" type="Map"/>
...@@ -1388,402 +1364,289 @@ def startTime = System.currentTimeMillis() ...@@ -1388,402 +1364,289 @@ def startTime = System.currentTimeMillis()
1388 <actions> 1364 <actions>
1389 <script><![CDATA[ 1365 <script><![CDATA[
1390 import org.moqui.context.ExecutionContext 1366 import org.moqui.context.ExecutionContext
1367 import org.moqui.mcp.McpUtils
1391 1368
1392 ExecutionContext ec = context.ec 1369 ExecutionContext ec = context.ec
1393 def startTime = System.currentTimeMillis() 1370 def subscreens = []
1394
1395 // Get user context
1396 def originalUsername = ec.user.username
1397 def originalUserId = ec.user.userId
1398 def userGroups = ec.user.getUserGroupIdSet().collect { it }
1399
1400 def tools = [] 1371 def tools = []
1401 adminUserInfo = null 1372 def currentPath = path
1402 1373
1403 try { 1374 // Logic to find screens
1404 adminUserInfo = ec.user.pushUser("ADMIN") 1375 if (!path || path == "/" || path == "root") {
1376 currentPath = "root"
1377 // Return known root apps
1378 // In a real implementation, we'd discover these from components
1379 def roots = ["PopCommerce", "SimpleScreens", "HiveMind", "Mantle"]
1405 1380
1406 def allScreens = [] as Set<String> 1381 for (root in roots) {
1407 1382 def toolName = "moqui_${root}"
1408 // Get screens accessible to user's groups 1383 subscreens << [
1409 def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") 1384 name: toolName,
1410 .condition("artifactTypeEnumId", "AT_XML_SCREEN") 1385 path: toolName,
1411 .condition("userGroupId", "in", userGroups) 1386 description: "Application: ${root}"
1412 .useCache(true) 1387 ]
1413 .disableAuthz()
1414 .list()
1415 allScreens = aacvList.collect { it.artifactName } as Set<String>
1416
1417 // Helper function to convert screen path to MCP tool name
1418 def screenPathToToolName = { screenPath ->
1419 // Clean Encoding: strip component:// and .xml, replace / with _
1420 def cleanPath = screenPath
1421 if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
1422 if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
1423
1424 // Extract just the screen name from the path (last part after /)
1425 def screenName = cleanPath.split('/')[-1]
1426
1427 return "screen_" + screenName
1428 } 1388 }
1429 1389 } else {
1430 // Helper function to convert screen path to MCP tool name with subscreen support 1390 // Resolve path
1431 def screenPathToToolNameWithSubscreens = { screenPath, parentScreenPath = null -> 1391 def screenPath = path.startsWith("moqui_") ? McpUtils.getScreenPath(path) : null
1432 // If we have a parent screen path, this is a subscreen - use dot notation 1392 if (!screenPath && !path.startsWith("component://")) {
1433 if (parentScreenPath) { 1393 // Try to handle "popcommerce/admin" style by guessing
1434 def parentCleanPath = parentScreenPath 1394 def toolName = "moqui_" + path.replace('/', '_')
1435 if (parentCleanPath.startsWith("component://")) parentCleanPath = parentCleanPath.substring(12) 1395 screenPath = McpUtils.getScreenPath(toolName)
1436 if (parentCleanPath.endsWith(".xml")) parentCleanPath = parentCleanPath.substring(0, parentCleanPath.length() - 4) 1396 } else if (path.startsWith("component://")) {
1437 1397 screenPath = path
1438 // Extract just the parent screen name (last part after /)
1439 def parentScreenName = parentCleanPath.split('/')[-1]
1440
1441 // Extract subscreen name from the full screen path
1442 def subscreenName = screenPath.split("/")[-1]
1443 if (subscreenName.endsWith(".xml")) subscreenName = subscreenName.substring(0, subscreenName.length() - 4)
1444
1445 return "screen_" + parentScreenName + "." + subscreenName
1446 }
1447
1448 // Regular screen path conversion for main screens
1449 return screenPathToToolName(screenPath)
1450 } 1398 }
1451 1399
1452 // Helper function to recursively process screens and create tools 1400 if (screenPath) {
1453 def processScreenWithSubscreens
1454 processScreenWithSubscreens = { screenPath, parentScreenPath = null, processedScreens = null, toolsAccumulator = null, parentToolName = null, level = 1 ->
1455 ec.logger.debug("list#Tools: Processing screen ${screenPath} (parent: ${parentScreenPath}, parentToolName: ${parentToolName}, level: ${level})")
1456
1457 // Initialize processedScreens and toolsAccumulator if null
1458 if (processedScreens == null) processedScreens = [] as Set<String>
1459 if (toolsAccumulator == null) toolsAccumulator = []
1460
1461 // Create a unique key for this specific access path (screen + parent)
1462 def accessPathKey = screenPath + "|" + (parentScreenPath ?: "ROOT")
1463
1464 if (processedScreens.contains(accessPathKey)) {
1465 ec.logger.debug("list#Tools: Already processed ${screenPath} from parent ${parentScreenPath}, skipping")
1466 return
1467 }
1468
1469 processedScreens.add(accessPathKey)
1470
1471 try { 1401 try {
1472 // Skip problematic patterns early 1402 // First, add the current screen itself as a tool if it's executable
1473 if (screenPath.contains("/error/") || screenPath.contains("/system/")) { 1403 def currentToolName = McpUtils.getToolName(screenPath)
1474 ec.logger.debug("list#Tools: Skipping system screen ${screenPath}") 1404 tools << [
1475 return 1405 name: currentToolName,
1476 } 1406 description: "Execute screen: ${currentToolName}",
1477 1407 type: "screen"
1478 // Determine if this is a subscreen 1408 ]
1479 def isSubscreen = parentScreenPath != null 1409
1480 1410 // Then look for subscreens
1481 // Try to get screen definition 1411 // Use getScreenInfoList with depth/mode? to ensure implicit subscreens are loaded
1482 def screenDefinition = null 1412 // The original list#Tools used getScreenInfoList(path, 1)
1483 def title = screenPath.split("/")[-1].replace('.xml', '') 1413 def screenInfoList = null
1484 def description = "Moqui screen: ${screenPath}"
1485
1486 try { 1414 try {
1487 screenDefinition = ec.screen.getScreenDefinition(screenPath) 1415 screenInfoList = ec.screen.getScreenInfoList(screenPath, 1)
1488 if (screenDefinition?.screenNode?.attribute('default-menu-title')) {
1489 title = screenDefinition.screenNode.attribute('default-menu-title')
1490 description = "Moqui screen: ${screenPath} (${title})"
1491 }
1492 } catch (Exception e) { 1416 } catch (Exception e) {
1493 ec.logger.debug("list#Tools: No screen definition for ${screenPath}, using basic info") 1417 ec.logger.debug("getScreenInfoList failed, trying getScreenInfo: ${e.message}")
1494 } 1418 }
1495 1419
1496 // Get screen parameters from transitions 1420 def screenInfo = screenInfoList ? screenInfoList.first() : ec.screen.getScreenInfo(screenPath)
1497 def parameters = [:]
1498 try {
1499 def screenInfo = ec.screen.getScreenInfo(screenPath)
1500 if (screenInfo?.transitionInfoByName) {
1501 for (transitionEntry in screenInfo.transitionInfoByName) {
1502 def transitionInfo = transitionEntry.value
1503 if (transitionInfo?.ti) {
1504 transitionInfo.ti.getPathParameterList()?.each { param ->
1505 parameters[param] = [
1506 type: "string",
1507 description: "Path parameter for transition: ${param}"
1508 ]
1509 }
1510 transitionInfo.ti.getRequestParameterList()?.each { param ->
1511 parameters[param.name] = [
1512 type: "string",
1513 description: "Request parameter: ${param.name}"
1514 ]
1515 }
1516 }
1517 }
1518 }
1519 } catch (Exception e) {
1520 ec.logger.debug("Could not extract parameters from screen ${screenPath}: ${e.message}")
1521 }
1522 1421
1523 // Create tool with proper naming 1422 if (screenInfo?.subscreenInfoByName) {
1524 def toolName 1423 for (entry in screenInfo.subscreenInfoByName) {
1525 if (isSubscreen && parentToolName) { 1424 def subName = entry.key
1526 // Use the passed hierarchical parent tool name and append current subscreen name 1425 def subInfo = entry.value
1527 def subscreenName = screenPath.split("/")[-1] 1426
1528 if (subscreenName.endsWith(".xml")) subscreenName = subscreenName.substring(0, subscreenName.length() - 4) 1427 // Construct sub-tool name by extending the parent path
1529 1428 // This assumes subscreens are structurally nested in the tool name
1530 // For level 1 subscreens, use dot notation 1429 // e.g. moqui_PopCommerce_Admin -> moqui_PopCommerce_Admin_Catalog
1531 // For level 2+, replace the last dot with underscore and add the new subscreen name 1430 def parentToolName = McpUtils.getToolName(screenPath)
1532 if (level == 2) { 1431 def subToolName = parentToolName + "_" + subName
1533 toolName = parentToolName + "." + subscreenName 1432
1534 } else { 1433 subscreens << [
1535 // Replace last dot with underscore and append new subscreen name 1434 name: subToolName,
1536 toolName = parentToolName + "_" + subscreenName // .replaceAll('\\.[^.]*$', '_' + subscreenName) 1435 path: subToolName,
1436 description: "Subscreen: ${subName}"
1437 ]
1537 } 1438 }
1538 ec.logger.debug("list#Tools: Creating subscreen tool ${toolName} for ${screenPath} (parentToolName: ${parentToolName}, level: ${level})")
1539 } else if (isSubscreen && parentScreenPath) {
1540 toolName = parentToolName + screenPathToToolNameWithSubscreens(screenPath, parentScreenPath)
1541 ec.logger.debug("list#Tools: Creating subscreen tool ${toolName} for ${screenPath} (parent: ${parentScreenPath})")
1542 } else {
1543 toolName = parentToolName + screenPathToToolName(screenPath)
1544 ec.logger.debug("list#Tools: Creating main screen tool ${toolName} for ${screenPath}")
1545 } 1439 }
1546 1440 } catch (Exception e) {
1547 def tool = [ 1441 ec.logger.warn("Browse error for ${screenPath}: ${e.message}")
1548 name: toolName, 1442 }
1549 title: title, 1443 }
1550 description: title, // Use title as description instead of redundant path 1444 }
1551 inputSchema: [ 1445
1552 type: "object", 1446 result = [
1553 properties: parameters, 1447 currentPath: currentPath,
1554 required: [] 1448 subscreens: subscreens,
1449 availableTools: tools,
1450 message: "Found ${subscreens.size()} subscreens and ${tools.size()} tools."
1451 ]
1452 ]]></script>
1453 </actions>
1454 </service>
1455
1456 <service verb="mcp" noun="SearchScreens" authenticate="false" allow-remote="true" transaction-timeout="60">
1457 <description>Search for screens by name or path.</description>
1458 <in-parameters>
1459 <parameter name="query" required="true"/>
1460 <parameter name="sessionId"/>
1461 </in-parameters>
1462 <out-parameters>
1463 <parameter name="result" type="Map"/>
1464 </out-parameters>
1465 <actions>
1466 <script><![CDATA[
1467 import org.moqui.context.ExecutionContext
1468 import org.moqui.mcp.McpUtils
1469
1470 ExecutionContext ec = context.ec
1471 def matches = []
1472
1473 // Search all screens known to the system (via authz rules)
1474 // Use disableAuthz() to bypass permission check on the system view itself
1475 def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
1476 .condition("artifactTypeEnumId", "AT_XML_SCREEN")
1477 .condition("artifactName", "like", "%${query}%")
1478 .selectField("artifactName")
1479 .distinct(true)
1480 .disableAuthz()
1481 .limit(20)
1482 .list()
1483
1484 for (hit in aacvList) {
1485 def toolName = McpUtils.getToolName(hit.artifactName)
1486 if (toolName) {
1487 matches << [
1488 name: toolName,
1489 description: "Screen: ${hit.artifactName}"
1490 ]
1491 }
1492 }
1493
1494 result = [matches: matches]
1495 ]]></script>
1496 </actions>
1497 </service>
1498
1499 <service verb="mcp" noun="GetScreenDetails" authenticate="false" allow-remote="true" transaction-timeout="30">
1500 <description>Get detailed schema and usage info for a specific screen tool.</description>
1501 <in-parameters>
1502 <parameter name="name" required="true"><description>Tool name (e.g. moqui_PopCommerce_...)</description></parameter>
1503 <parameter name="sessionId"/>
1504 </in-parameters>
1505 <out-parameters>
1506 <parameter name="result" type="Map"/>
1507 </out-parameters>
1508 <actions>
1509 <script><![CDATA[
1510 import org.moqui.context.ExecutionContext
1511 import org.moqui.mcp.McpUtils
1512
1513 ExecutionContext ec = context.ec
1514 def screenPath = McpUtils.getScreenPath(name)
1515 ec.logger.info("GetScreenDetails: name=${name} -> screenPath=${screenPath}")
1516 def toolDef = null
1517
1518 if (screenPath) {
1519 try {
1520 def parameters = [:]
1521 // Use getScreenDefinition for stable access to parameters
1522 def screenDef = ec.screen.getScreenDefinition(screenPath)
1523
1524 if (screenDef && screenDef.screenNode) {
1525 // Extract parameters from XML node
1526 def parameterNodes = screenDef.screenNode.children("parameter")
1527 for (node in parameterNodes) {
1528 def paramName = node.attribute("name")
1529 parameters[paramName] = [
1530 type: "string",
1531 description: "Screen Parameter"
1555 ] 1532 ]
1556 ] 1533 }
1557
1558 ec.logger.debug("list#Tools: Adding accessible screen tool ${toolName} for ${screenPath}")
1559 toolsAccumulator << tool
1560 1534
1561 // Recursively process subscreens 1535 // Also extract transition parameters if possible
1562 try { 1536 def transitionNodes = screenDef.screenNode.children("transition")
1563 def screenInfoList = ec.screen.getScreenInfoList(screenPath, 1) 1537 for (node in transitionNodes) {
1564 def screenInfo = screenInfoList?.first() 1538 // Transition parameters
1565 if (screenInfo?.subscreenInfoByName) { 1539 def tParams = node.children("parameter")
1566 ec.logger.debug("list#Tools: Found ${screenInfo.subscreenInfoByName.size()} subscreens for ${screenPath}: ${screenInfo.subscreenInfoByName.keySet()}") 1540 for (tp in tParams) {
1567 for (subScreenEntry in screenInfo.subscreenInfoByName) { 1541 def tpName = tp.attribute("name")
1568 def subScreenInfo = subScreenEntry.value 1542 if (!parameters[tpName]) {
1569 def subScreenPathList = subScreenInfo?.screenPath 1543 parameters[tpName] = [
1570 ec.logger.debug("list#Tools: Processing subscreen ${subScreenEntry.key}, subScreenInfo.screenPath: ${subScreenInfo.screenPath}") 1544 type: "string",
1571 1545 description: "Transition Parameter for ${node.attribute('name')}"
1572 // Get the actual subscreen location from screenInfo (should have correct cross-component paths) 1546 ]
1573 def actualSubScreenPath = null
1574
1575 if (subScreenInfo?.screenPath) {
1576 // Try to get actual screen location from subScreenInfo
1577 try {
1578 // Check if subScreenInfo has a method to get actual screen location
1579 if (subScreenInfo.hasProperty('sd')) {
1580 // Try to get location from screen definition
1581 def screenDef = subScreenInfo.sd
1582 if (screenDef?.hasProperty('screenLocation')) {
1583 actualSubScreenPath = screenDef.screenLocation
1584 ec.logger.debug("list#Tools: Found screenLocation from sd for ${subScreenEntry.key}: ${actualSubScreenPath}")
1585 } else if (screenDef?.hasProperty('location')) {
1586 actualSubScreenPath = screenDef.location
1587 ec.logger.debug("list#Tools: Found location from sd for ${subScreenEntry.key}: ${actualSubScreenPath}")
1588 }
1589 }
1590
1591 // Fallback to checking if screenPath contains XML path
1592 if (!actualSubScreenPath) {
1593 if (subScreenInfo.screenPath instanceof List) {
1594 // This is a list of all subscreens/transitions, not a path
1595 // Don't treat it as a path - use other methods to find location
1596 ec.logger.debug("list#Tools: screenPath is a list, not using for path resolution for ${subScreenEntry.key}")
1597 } else {
1598 actualSubScreenPath = subScreenInfo.screenPath.toString()
1599 }
1600 }
1601
1602 } catch (Exception e) {
1603 ec.logger.debug("list#Tools: Error getting screen location from subScreenInfo for ${subScreenEntry.key}: ${e.message}")
1604 }
1605 }
1606
1607 // Fallback: try XML parsing if screenInfo doesn't have the path
1608 if (!actualSubScreenPath) {
1609 try {
1610 def parentScreenDef = ec.screen.getScreenDefinition(screenPath)
1611 if (parentScreenDef?.screenNode) {
1612 def subscreensNode = parentScreenDef.screenNode.first("subscreens")
1613 if (subscreensNode) {
1614 def subscreenItems = []
1615 try {
1616 subscreenItems = subscreensNode."subscreens-item"
1617 } catch (Exception e) {
1618 def allChildren = subscreensNode.children()
1619 subscreenItems = allChildren.findAll {
1620 it.name() == "subscreens-item"
1621 }
1622 }
1623
1624 def subscreenItem = null
1625 for (item in subscreenItems) {
1626 if (item.hasAttribute('name') && item.attribute('name') == subScreenEntry.key) {
1627 subscreenItem = item
1628 break
1629 }
1630 }
1631 if (subscreenItem?.hasAttribute('location')) {
1632 actualSubScreenPath = subscreenItem.attribute('location')
1633 ec.logger.debug("list#Tools: Found XML location for ${subScreenEntry.key}: ${actualSubScreenPath}")
1634 }
1635 }
1636 }
1637 } catch (Exception e) {
1638 ec.logger.info("Could not get subscreen location from XML for ${subScreenEntry.key}: ${e.message}")
1639 }
1640 }
1641
1642 // Final fallback: construct from screenPath if we couldn't get the actual location
1643 if (!actualSubScreenPath) {
1644 def subscreenName = subScreenEntry.key
1645 def currentScreenPath = screenPath
1646 def lastSlash = currentScreenPath.lastIndexOf('/')
1647 if (lastSlash > 0) {
1648 def basePath = currentScreenPath.substring(0, lastSlash + 1)
1649 actualSubScreenPath = basePath + subscreenName + ".xml"
1650 ec.logger.debug("list#Tools: Constructed fallback path for ${subScreenEntry.key}: ${actualSubScreenPath}")
1651 }
1652 }
1653
1654 if (actualSubScreenPath) {
1655 ec.logger.debug("list#Tools: Adding subscreen ${actualSubScreenPath} ${screenPath} ${toolName} ${level+1}")
1656 processScreenWithSubscreens(actualSubScreenPath, screenPath, processedScreens, toolsAccumulator, toolName, level + 1)
1657 } else if (!actualSubScreenPath) {
1658 // For screens without explicit location, try automatic discovery
1659 def lastSlash = screenPath.lastIndexOf('/')
1660 if (lastSlash > 0) {
1661 def basePath = screenPath.substring(0, lastSlash + 1)
1662 def autoSubScreenPath = basePath + subScreenEntry.key + ".xml"
1663 ec.logger.debug("list#Tools: Constructed fallback path for ${subScreenEntry.key}: ${actualSubScreenPath}")
1664 processScreenWithSubscreens(autoSubScreenPath, screenPath, processedScreens, toolsAccumulator, toolName, level + 1)
1665 }
1666 }
1667 } 1547 }
1668 } 1548 }
1669 } catch (Exception e) {
1670 ec.logger.debug("Could not get subscreens for ${screenPath}: ${e.message}")
1671 } 1549 }
1672
1673 } catch (Exception e) {
1674 ec.logger.warn("Error processing screen ${screenPath}: ${e.message}")
1675 } 1550 }
1676 } 1551
1677 1552 toolDef = [
1678 // Process all accessible screens recursively 1553 name: name,
1679 def processedScreens = [] as Set<String> 1554 description: "Full details for ${name} (${screenPath})",
1680 ec.logger.info("list#Tools: Starting recursive processing from ${allScreens.size()} base screens")
1681 for (screenPath in allScreens) {
1682 def parentToolPath = 'screen_' + screenPath.split('/')[-3..-3].join('_').replace('.xml', '') + '_'
1683 ec.logger.debug("TOPSCREEN: ${parentToolPath}")
1684 processScreenWithSubscreens(screenPath, null, processedScreens, tools, parentToolPath, 0)
1685 }
1686 ec.logger.info("list#Tools: Recursive processing found ${tools.size()} total tools")
1687
1688 // Add standard MCP protocol methods that clients can discover
1689 def standardMcpMethods = [
1690 [
1691 name: "tools/list",
1692 title: "List Available Tools",
1693 description: "Get a list of all available MCP tools including Moqui services and screens",
1694 inputSchema: [
1695 type: "object",
1696 properties: [
1697 cursor: [
1698 type: "string",
1699 description: "Pagination cursor for large tool lists"
1700 ]
1701 ],
1702 required: []
1703 ]
1704 ],
1705 [
1706 name: "tools/call",
1707 title: "Execute Tool",
1708 description: "Execute a specific MCP tool by name with parameters",
1709 inputSchema: [
1710 type: "object",
1711 properties: [
1712 name: [
1713 type: "string",
1714 description: "Name of the tool to execute"
1715 ],
1716 arguments: [
1717 type: "object",
1718 description: "Parameters to pass to the tool"
1719 ]
1720 ],
1721 required: ["name"]
1722 ]
1723 ],
1724 [
1725 name: "resources/list",
1726 title: "List Resources",
1727 description: "Get a list of available MCP resources (Moqui entities)",
1728 inputSchema: [
1729 type: "object",
1730 properties: [
1731 cursor: [
1732 type: "string",
1733 description: "Pagination cursor for large resource lists"
1734 ]
1735 ],
1736 required: []
1737 ]
1738 ],
1739 [
1740 name: "resources/read",
1741 title: "Read Resource",
1742 description: "Read data from a specific MCP resource (Moqui entity)",
1743 inputSchema: [
1744 type: "object",
1745 properties: [
1746 uri: [
1747 type: "string",
1748 description: "Resource URI to read (format: entity://EntityName)"
1749 ]
1750 ],
1751 required: ["uri"]
1752 ]
1753 ],
1754 [
1755 name: "ping",
1756 title: "Ping Server",
1757 description: "Test connectivity to the MCP server and get session info",
1758 inputSchema: [ 1555 inputSchema: [
1759 type: "object", 1556 type: "object",
1760 properties: [:], 1557 properties: parameters
1761 required: []
1762 ] 1558 ]
1763 ] 1559 ]
1764 ] 1560 } catch (Exception e) {
1765 1561 ec.logger.warn("Error getting screen details for ${name}: ${e.message}")
1766 tools.addAll(0, standardMcpMethods) // Add at beginning so they appear on first page
1767 ec.logger.debug("list#Tools: Added ${standardMcpMethods.size()} standard MCP protocol methods")
1768
1769 } finally {
1770 if (adminUserInfo != null) {
1771 ec.user.popUser()
1772 } 1562 }
1773 } 1563 }
1774 1564
1775 // Pagination (same as original) 1565 result = [tool: toolDef]
1776 def pageSize = 50 1566 ]]></script>
1777 def startIndex = cursor ? Integer.parseInt(cursor) : 0 1567 </actions>
1778 def endIndex = Math.min(startIndex + pageSize, tools.size()) 1568 </service>
1779 def paginatedTools = tools.subList(startIndex, endIndex) 1569
1570 <service verb="list" noun="Tools" authenticate="false" allow-remote="true" transaction-timeout="60">
1571 <description>List discovery tools and root apps.</description>
1572 <in-parameters>
1573 <parameter name="sessionId"/>
1574 <parameter name="cursor"/>
1575 </in-parameters>
1576 <out-parameters>
1577 <parameter name="result" type="Map"/>
1578 </out-parameters>
1579 <actions>
1580 <script><![CDATA[
1581 import org.moqui.context.ExecutionContext
1780 1582
1781 result = [tools: paginatedTools] 1583 ExecutionContext ec = context.ec
1782 if (endIndex < tools.size()) { 1584
1783 result.nextCursor = String.valueOf(endIndex) 1585 // Static list of Discovery Tools
1784 } 1586 def tools = [
1587 [
1588 name: "moqui_browse_screens",
1589 title: "Browse Screens",
1590 description: "Browse the Moqui screen hierarchy. Use this to discover capabilities. Input 'path' (empty for root).",
1591 inputSchema: [
1592 type: "object",
1593 properties: [
1594 path: [type: "string", description: "Path to browse (e.g. 'moqui_PopCommerce')"]
1595 ]
1596 ]
1597 ],
1598 [
1599 name: "moqui_search_screens",
1600 title: "Search Screens",
1601 description: "Search for screens/functionality by name.",
1602 inputSchema: [
1603 type: "object",
1604 properties: [
1605 query: [type: "string", description: "Search query"]
1606 ],
1607 required: ["query"]
1608 ]
1609 ],
1610 [
1611 name: "moqui_get_screen_details",
1612 title: "Get Screen Details",
1613 description: "Get input schema and details for a specific tool/screen.",
1614 inputSchema: [
1615 type: "object",
1616 properties: [
1617 name: [type: "string", description: "Tool name"]
1618 ],
1619 required: ["name"]
1620 ]
1621 ]
1622 ]
1623
1624 // Add standard MCP methods
1625 def standardMcpMethods = [
1626 [
1627 name: "tools/list",
1628 title: "List Available Tools",
1629 description: "Get a list of all available MCP tools",
1630 inputSchema: [type: "object", properties: [:], required: []]
1631 ],
1632 [
1633 name: "tools/call",
1634 title: "Execute Tool",
1635 description: "Execute a specific MCP tool",
1636 inputSchema: [
1637 type: "object",
1638 properties: [
1639 name: [type: "string"],
1640 arguments: [type: "object"]
1641 ],
1642 required: ["name"]
1643 ]
1644 ]
1645 ]
1646
1647 tools.addAll(standardMcpMethods)
1785 1648
1786 ec.logger.info("list#Tools: Found ${tools.size()} tools for user ${originalUsername}") 1649 result = [tools: tools]
1787 ]]></script> 1650 ]]></script>
1788 </actions> 1651 </actions>
1789 </service> 1652 </service>
......
1 package org.moqui.mcp
2
3 import org.moqui.util.MNode
4
5 class McpUtils {
6 /**
7 * Convert a Moqui screen path to an MCP tool name.
8 * Preserves case to ensure reversibility.
9 * Format: moqui_<Component>_<Path_Parts>
10 * Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog
11 */
12 static String getToolName(String screenPath) {
13 if (!screenPath) return null
14
15 // Strip component:// prefix and .xml suffix
16 String cleanPath = screenPath
17 if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
18 if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
19
20 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") {
24 parts.remove(1)
25 }
26
27 // Join with underscores and prefix
28 return "moqui_" + parts.join('_')
29 }
30
31 /**
32 * Convert an MCP tool name back to a Moqui screen path.
33 * Assumes standard component://<Component>/screen/<Path>.xml structure.
34 */
35 static String getScreenPath(String toolName) {
36 if (!toolName || !toolName.startsWith("moqui_")) return null
37
38 String cleanName = toolName.substring(6) // Remove moqui_
39 List<String> parts = cleanName.split('_').toList()
40
41 if (parts.size() < 1) return null
42
43 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) {
48 // Fallback for component roots? unlikely to be a valid screen path without 'screen' dir
49 return "component://${component}/screen/${component}.xml"
50 }
51
52 // Re-insert 'screen' directory which is standard
53 String path = parts.subList(1, parts.size()).join('/')
54 return "component://${component}/screen/${path}.xml"
55 }
56 }