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,30 +174,45 @@ ...@@ -171,30 +174,45 @@
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
186 if (isLegacyScreen) {
187 // Decode legacy screen path from tool name
188 def toolNameSuffix = actualToolName.substring(7) // Remove "screen_" prefix
189
183 // Check if this is a subscreen (contains dot after initial prefix) 190 // Check if this is a subscreen (contains dot after initial prefix)
184 if (toolNameSuffix.contains('.')) { 191 if (toolNameSuffix.contains('.')) {
185 // Split on dot to separate parent screen path from subscreen name
186 def lastDotIndex = toolNameSuffix.lastIndexOf('.') 192 def lastDotIndex = toolNameSuffix.lastIndexOf('.')
187 def parentPath = toolNameSuffix.substring(0, lastDotIndex) 193 def parentPath = toolNameSuffix.substring(0, lastDotIndex)
188 subscreenName = toolNameSuffix.substring(lastDotIndex + 1) 194 subscreenName = toolNameSuffix.substring(lastDotIndex + 1)
189
190 // Restore parent path: _ -> /, prepend component://, append .xml
191 screenPath = "component://" + parentPath.replace('_', '/') + ".xml" 195 screenPath = "component://" + parentPath.replace('_', '/') + ".xml"
192 ec.logger.info("MCP ToolsCall: Decoded screen tool - parent=${screenPath}, subscreen=${subscreenName}")
193 } else { 196 } else {
194 // Regular screen path: _ -> /, prepend component://, append .xml
195 screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml" 197 screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml"
196 ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}")
197 } 198 }
199 } else {
200 // For moqui_ tools, check existence and fallback to subscreen
201 if (!ec.resource.getLocationReference(screenPath).getExists()) {
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 }
213 }
214
215 ec.logger.info("MCP ToolsCall: Decoded screen tool - screenPath=${screenPath}, subscreen=${subscreenName}")
198 216
199 // Call screen execution service with decoded parameters 217 // Call screen execution service with decoded parameters
200 def screenCallParams = [ 218 def screenCallParams = [
...@@ -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
285 def lastSlash = screenPath.lastIndexOf('/')
286 if (lastSlash > 0) {
287 def parentPath = screenPath.substring(0, lastSlash) + ".xml"
288 // The subscreen name is the part after the slash, without .xml
289 // But wait, screenPath from McpUtils ends in .xml
290 // screenPath: .../Catalog/Product/FindProduct.xml
291 // subscreenName: FindProduct
292 def possibleSubscreen = screenPath.substring(lastSlash + 1).replace('.xml', '')
269 293
270 // Restore parent path: _ -> /, prepend component://, append .xml 294 if (ec.resource.getLocationReference(parentPath).getExists()) {
271 screenPath = "component://" + parentPath.replace('_', '/') + ".xml" 295 screenPath = parentPath
272 ec.logger.info("Decoded subscreen path for tool ${name}: parent=${screenPath}, subscreen=${subscreenName}") 296 subscreenName = possibleSubscreen
273 } else { 297 ec.logger.info("Found parent screen: ${screenPath}, subscreen: ${subscreenName}")
274 // Regular screen path: _ -> /, prepend component://, append .xml 298 }
275 screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml" 299 }
276 ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}")
277 } 300 }
278 301
279 // Now call the screen tool with proper user context 302 // Now call the screen tool with proper user context
...@@ -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,119 +532,74 @@ ...@@ -511,119 +532,74 @@
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
529 try {
530 // Try to get entity definition - handle both real entities and view entities
531 def entityDef = null 549 def entityDef = null
532 try { 550 try {
533 // First try getAllEntityInfo for detailed info 551 def entityInfoList = ec.entity.getAllEntityInfo(-1, true)
534 def entityInfoList = ec.entity.getAllEntityInfo(-1, true) // all entities, include view entities
535 entityDef = entityInfoList.find { it.entityName == entityName } 552 entityDef = entityInfoList.find { it.entityName == entityName }
536 553 } catch (Exception e) {
537 if (!entityDef) { 554 ec.logger.debug("Error getting detailed entity info: ${e.message}")
538 // If not found in detailed list, try basic entity check
539 if (ec.entity.isEntityDefined(entityName)) {
540 // Create minimal entity definition for basic query
541 entityDef = [
542 entityName: entityName,
543 packageName: entityName.split('\\.')[0],
544 description: "Entity: ${entityName}",
545 isViewEntity: entityName.contains('View'),
546 allFieldInfoList: []
547 ]
548 }
549 } 555 }
550 556
551 ec.logger.warn("ResourcesRead: Error getting entity info for ${entityName}: ${e.message}") 557 if (!entityDef) {
552 // Fallback: try basic entity check
553 if (ec.entity.isEntityDefined(entityName)) {
554 entityDef = [ 558 entityDef = [
555 entityName: entityName, 559 entityName: entityName,
556 packageName: entityName.split('\\.')[0], 560 packageName: entityName.contains('.') ? entityName.split('\\.')[0] : "",
557 description: "Entity: ${entityName}", 561 description: "Entity: ${entityName}",
558 isViewEntity: entityName.contains('View'), 562 isViewEntity: entityName.contains('View'),
559 allFieldInfoList: [] 563 allFieldInfoList: []
560 ] 564 ]
561 } 565 }
562 566
563 if (!entityDef) { 567 // Query entity data
564 throw new Exception("Entity not found: ${entityName}") 568 def entityList = ec.entity.find(entityName).limit(100).list()
565 }
566
567 ec.logger.info("ResourcesRead: Found entity ${entityName}, isViewEntity=${entityDef.isViewEntity}")
568
569 // Query entity data (limited to prevent large responses)
570 def entityList = ec.entity.find(entityName)
571 .limit(100)
572 .list()
573
574 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
575 569
576 // SIZE PROTECTION: Check response size before returning 570 // Format response
577 def jsonOutput = new JsonBuilder([ 571 def responseMap = [
578 entityName: entityName, 572 entityName: entityName,
579 description: entityDef.description ?: "", 573 description: entityDef.description,
580 packageName: entityDef.packageName, 574 packageName: entityDef.packageName,
581 recordCount: entityList.size(), 575 recordCount: entityList.size(),
582 fields: fieldInfo,
583 data: entityList 576 data: entityList
584 ]).toString() 577 ]
585 578
586 def maxResponseSize = 1024 * 1024 // 1MB limit 579 def jsonOutput = new JsonBuilder(responseMap).toString()
580
581 // Size protection
582 def maxResponseSize = 1024 * 1024 // 1MB
587 if (jsonOutput.length() > maxResponseSize) { 583 if (jsonOutput.length() > maxResponseSize) {
588 ec.logger.warn("ResourcesRead: Response too large for ${entityName}: ${jsonOutput.length()} bytes (limit: ${maxResponseSize} bytes)") 584 def truncatedList = entityList.take(10)
585 responseMap.data = truncatedList
586 responseMap.truncated = true
587 responseMap.message = "Truncated to 10 records due to size."
588 jsonOutput = new JsonBuilder(responseMap).toString()
589 }
589 590
590 // Create truncated response with fewer records 591 result = [
591 def truncatedList = entityList.take(10) // Keep only first 10 records 592 content: [[
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, 593 uri: uri,
617 mimeType: "application/json", 594 mimeType: "application/json",
618 text: jsonOutput 595 text: jsonOutput
596 ]],
597 isError: false
619 ] 598 ]
620 ]
621 }
622 599
623 } catch (Exception e) { 600 } catch (Exception e) {
624 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 601 ec.logger.error("Error reading resource ${uri}", e)
625 ec.logger.warn("Error reading resource ${uri}: ${e.message}") 602 result = [isError: true, content: [[type:"text", text: e.message]]]
626 result = [error: "Error reading resource ${uri}: ${e.message}"]
627 } 603 }
628 ]]></script> 604 ]]></script>
629 </actions> 605 </actions>
...@@ -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") {
1405 1376 currentPath = "root"
1406 def allScreens = [] as Set<String> 1377 // Return known root apps
1407 1378 // In a real implementation, we'd discover these from components
1408 // Get screens accessible to user's groups 1379 def roots = ["PopCommerce", "SimpleScreens", "HiveMind", "Mantle"]
1409 def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") 1380
1410 .condition("artifactTypeEnumId", "AT_XML_SCREEN") 1381 for (root in roots) {
1411 .condition("userGroupId", "in", userGroups) 1382 def toolName = "moqui_${root}"
1412 .useCache(true) 1383 subscreens << [
1413 .disableAuthz() 1384 name: toolName,
1414 .list() 1385 path: toolName,
1415 allScreens = aacvList.collect { it.artifactName } as Set<String> 1386 description: "Application: ${root}"
1416 1387 ]
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 }
1429
1430 // Helper function to convert screen path to MCP tool name with subscreen support
1431 def screenPathToToolNameWithSubscreens = { screenPath, parentScreenPath = null ->
1432 // If we have a parent screen path, this is a subscreen - use dot notation
1433 if (parentScreenPath) {
1434 def parentCleanPath = parentScreenPath
1435 if (parentCleanPath.startsWith("component://")) parentCleanPath = parentCleanPath.substring(12)
1436 if (parentCleanPath.endsWith(".xml")) parentCleanPath = parentCleanPath.substring(0, parentCleanPath.length() - 4)
1437
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 } 1388 }
1451 1389 } else {
1452 // Helper function to recursively process screens and create tools 1390 // Resolve path
1453 def processScreenWithSubscreens 1391 def screenPath = path.startsWith("moqui_") ? McpUtils.getScreenPath(path) : null
1454 processScreenWithSubscreens = { screenPath, parentScreenPath = null, processedScreens = null, toolsAccumulator = null, parentToolName = null, level = 1 -> 1392 if (!screenPath && !path.startsWith("component://")) {
1455 ec.logger.debug("list#Tools: Processing screen ${screenPath} (parent: ${parentScreenPath}, parentToolName: ${parentToolName}, level: ${level})") 1393 // Try to handle "popcommerce/admin" style by guessing
1456 1394 def toolName = "moqui_" + path.replace('/', '_')
1457 // Initialize processedScreens and toolsAccumulator if null 1395 screenPath = McpUtils.getScreenPath(toolName)
1458 if (processedScreens == null) processedScreens = [] as Set<String> 1396 } else if (path.startsWith("component://")) {
1459 if (toolsAccumulator == null) toolsAccumulator = [] 1397 screenPath = path
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 } 1398 }
1468 1399
1469 processedScreens.add(accessPathKey) 1400 if (screenPath) {
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
1480
1481 // Try to get screen definition
1482 def screenDefinition = null
1483 def title = screenPath.split("/")[-1].replace('.xml', '')
1484 def description = "Moqui screen: ${screenPath}"
1485 1409
1410 // Then look for subscreens
1411 // Use getScreenInfoList with depth/mode? to ensure implicit subscreens are loaded
1412 // The original list#Tools used getScreenInfoList(path, 1)
1413 def screenInfoList = null
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 = [:] 1421
1498 try { 1422 if (screenInfo?.subscreenInfoByName) {
1499 def screenInfo = ec.screen.getScreenInfo(screenPath) 1423 for (entry in screenInfo.subscreenInfoByName) {
1500 if (screenInfo?.transitionInfoByName) { 1424 def subName = entry.key
1501 for (transitionEntry in screenInfo.transitionInfoByName) { 1425 def subInfo = entry.value
1502 def transitionInfo = transitionEntry.value 1426
1503 if (transitionInfo?.ti) { 1427 // Construct sub-tool name by extending the parent path
1504 transitionInfo.ti.getPathParameterList()?.each { param -> 1428 // This assumes subscreens are structurally nested in the tool name
1505 parameters[param] = [ 1429 // e.g. moqui_PopCommerce_Admin -> moqui_PopCommerce_Admin_Catalog
1506 type: "string", 1430 def parentToolName = McpUtils.getToolName(screenPath)
1507 description: "Path parameter for transition: ${param}" 1431 def subToolName = parentToolName + "_" + subName
1508 ] 1432
1509 } 1433 subscreens << [
1510 transitionInfo.ti.getRequestParameterList()?.each { param -> 1434 name: subToolName,
1511 parameters[param.name] = [ 1435 path: subToolName,
1512 type: "string", 1436 description: "Subscreen: ${subName}"
1513 description: "Request parameter: ${param.name}"
1514 ] 1437 ]
1515 } 1438 }
1516 } 1439 }
1517 }
1518 }
1519 } catch (Exception e) { 1440 } catch (Exception e) {
1520 ec.logger.debug("Could not extract parameters from screen ${screenPath}: ${e.message}") 1441 ec.logger.warn("Browse error for ${screenPath}: ${e.message}")
1521 } 1442 }
1522
1523 // Create tool with proper naming
1524 def toolName
1525 if (isSubscreen && parentToolName) {
1526 // Use the passed hierarchical parent tool name and append current subscreen name
1527 def subscreenName = screenPath.split("/")[-1]
1528 if (subscreenName.endsWith(".xml")) subscreenName = subscreenName.substring(0, subscreenName.length() - 4)
1529
1530 // For level 1 subscreens, use dot notation
1531 // For level 2+, replace the last dot with underscore and add the new subscreen name
1532 if (level == 2) {
1533 toolName = parentToolName + "." + subscreenName
1534 } else {
1535 // Replace last dot with underscore and append new subscreen name
1536 toolName = parentToolName + "_" + subscreenName // .replaceAll('\\.[^.]*$', '_' + subscreenName)
1537 } 1443 }
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 } 1444 }
1546 1445
1547 def tool = [ 1446 result = [
1548 name: toolName, 1447 currentPath: currentPath,
1549 title: title, 1448 subscreens: subscreens,
1550 description: title, // Use title as description instead of redundant path 1449 availableTools: tools,
1551 inputSchema: [ 1450 message: "Found ${subscreens.size()} subscreens and ${tools.size()} tools."
1552 type: "object",
1553 properties: parameters,
1554 required: []
1555 ]
1556 ] 1451 ]
1452 ]]></script>
1453 </actions>
1454 </service>
1557 1455
1558 ec.logger.debug("list#Tools: Adding accessible screen tool ${toolName} for ${screenPath}") 1456 <service verb="mcp" noun="SearchScreens" authenticate="false" allow-remote="true" transaction-timeout="60">
1559 toolsAccumulator << tool 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
1560 1469
1561 // Recursively process subscreens 1470 ExecutionContext ec = context.ec
1562 try { 1471 def matches = []
1563 def screenInfoList = ec.screen.getScreenInfoList(screenPath, 1)
1564 def screenInfo = screenInfoList?.first()
1565 if (screenInfo?.subscreenInfoByName) {
1566 ec.logger.debug("list#Tools: Found ${screenInfo.subscreenInfoByName.size()} subscreens for ${screenPath}: ${screenInfo.subscreenInfoByName.keySet()}")
1567 for (subScreenEntry in screenInfo.subscreenInfoByName) {
1568 def subScreenInfo = subScreenEntry.value
1569 def subScreenPathList = subScreenInfo?.screenPath
1570 ec.logger.debug("list#Tools: Processing subscreen ${subScreenEntry.key}, subScreenInfo.screenPath: ${subScreenInfo.screenPath}")
1571 1472
1572 // Get the actual subscreen location from screenInfo (should have correct cross-component paths) 1473 // Search all screens known to the system (via authz rules)
1573 def actualSubScreenPath = null 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()
1574 1483
1575 if (subScreenInfo?.screenPath) { 1484 for (hit in aacvList) {
1576 // Try to get actual screen location from subScreenInfo 1485 def toolName = McpUtils.getToolName(hit.artifactName)
1577 try { 1486 if (toolName) {
1578 // Check if subScreenInfo has a method to get actual screen location 1487 matches << [
1579 if (subScreenInfo.hasProperty('sd')) { 1488 name: toolName,
1580 // Try to get location from screen definition 1489 description: "Screen: ${hit.artifactName}"
1581 def screenDef = subScreenInfo.sd 1490 ]
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 } 1491 }
1600 } 1492 }
1601 1493
1602 } catch (Exception e) { 1494 result = [matches: matches]
1603 ec.logger.debug("list#Tools: Error getting screen location from subScreenInfo for ${subScreenEntry.key}: ${e.message}") 1495 ]]></script>
1604 } 1496 </actions>
1605 } 1497 </service>
1606 1498
1607 // Fallback: try XML parsing if screenInfo doesn't have the path 1499 <service verb="mcp" noun="GetScreenDetails" authenticate="false" allow-remote="true" transaction-timeout="30">
1608 if (!actualSubScreenPath) { 1500 <description>Get detailed schema and usage info for a specific screen tool.</description>
1609 try { 1501 <in-parameters>
1610 def parentScreenDef = ec.screen.getScreenDefinition(screenPath) 1502 <parameter name="name" required="true"><description>Tool name (e.g. moqui_PopCommerce_...)</description></parameter>
1611 if (parentScreenDef?.screenNode) { 1503 <parameter name="sessionId"/>
1612 def subscreensNode = parentScreenDef.screenNode.first("subscreens") 1504 </in-parameters>
1613 if (subscreensNode) { 1505 <out-parameters>
1614 def subscreenItems = [] 1506 <parameter name="result" type="Map"/>
1615 try { 1507 </out-parameters>
1616 subscreenItems = subscreensNode."subscreens-item" 1508 <actions>
1617 } catch (Exception e) { 1509 <script><![CDATA[
1618 def allChildren = subscreensNode.children() 1510 import org.moqui.context.ExecutionContext
1619 subscreenItems = allChildren.findAll { 1511 import org.moqui.mcp.McpUtils
1620 it.name() == "subscreens-item"
1621 }
1622 }
1623 1512
1624 def subscreenItem = null 1513 ExecutionContext ec = context.ec
1625 for (item in subscreenItems) { 1514 def screenPath = McpUtils.getScreenPath(name)
1626 if (item.hasAttribute('name') && item.attribute('name') == subScreenEntry.key) { 1515 ec.logger.info("GetScreenDetails: name=${name} -> screenPath=${screenPath}")
1627 subscreenItem = item 1516 def toolDef = null
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 1517
1642 // Final fallback: construct from screenPath if we couldn't get the actual location 1518 if (screenPath) {
1643 if (!actualSubScreenPath) { 1519 try {
1644 def subscreenName = subScreenEntry.key 1520 def parameters = [:]
1645 def currentScreenPath = screenPath 1521 // Use getScreenDefinition for stable access to parameters
1646 def lastSlash = currentScreenPath.lastIndexOf('/') 1522 def screenDef = ec.screen.getScreenDefinition(screenPath)
1647 if (lastSlash > 0) { 1523
1648 def basePath = currentScreenPath.substring(0, lastSlash + 1) 1524 if (screenDef && screenDef.screenNode) {
1649 actualSubScreenPath = basePath + subscreenName + ".xml" 1525 // Extract parameters from XML node
1650 ec.logger.debug("list#Tools: Constructed fallback path for ${subScreenEntry.key}: ${actualSubScreenPath}") 1526 def parameterNodes = screenDef.screenNode.children("parameter")
1651 } 1527 for (node in parameterNodes) {
1528 def paramName = node.attribute("name")
1529 parameters[paramName] = [
1530 type: "string",
1531 description: "Screen Parameter"
1532 ]
1652 } 1533 }
1653 1534
1654 if (actualSubScreenPath) { 1535 // Also extract transition parameters if possible
1655 ec.logger.debug("list#Tools: Adding subscreen ${actualSubScreenPath} ${screenPath} ${toolName} ${level+1}") 1536 def transitionNodes = screenDef.screenNode.children("transition")
1656 processScreenWithSubscreens(actualSubScreenPath, screenPath, processedScreens, toolsAccumulator, toolName, level + 1) 1537 for (node in transitionNodes) {
1657 } else if (!actualSubScreenPath) { 1538 // Transition parameters
1658 // For screens without explicit location, try automatic discovery 1539 def tParams = node.children("parameter")
1659 def lastSlash = screenPath.lastIndexOf('/') 1540 for (tp in tParams) {
1660 if (lastSlash > 0) { 1541 def tpName = tp.attribute("name")
1661 def basePath = screenPath.substring(0, lastSlash + 1) 1542 if (!parameters[tpName]) {
1662 def autoSubScreenPath = basePath + subScreenEntry.key + ".xml" 1543 parameters[tpName] = [
1663 ec.logger.debug("list#Tools: Constructed fallback path for ${subScreenEntry.key}: ${actualSubScreenPath}") 1544 type: "string",
1664 processScreenWithSubscreens(autoSubScreenPath, screenPath, processedScreens, toolsAccumulator, toolName, level + 1) 1545 description: "Transition Parameter for ${node.attribute('name')}"
1665 } 1546 ]
1666 } 1547 }
1667 } 1548 }
1668 } 1549 }
1669 } catch (Exception e) {
1670 ec.logger.debug("Could not get subscreens for ${screenPath}: ${e.message}")
1671 } 1550 }
1672 1551
1552 toolDef = [
1553 name: name,
1554 description: "Full details for ${name} (${screenPath})",
1555 inputSchema: [
1556 type: "object",
1557 properties: parameters
1558 ]
1559 ]
1673 } catch (Exception e) { 1560 } catch (Exception e) {
1674 ec.logger.warn("Error processing screen ${screenPath}: ${e.message}") 1561 ec.logger.warn("Error getting screen details for ${name}: ${e.message}")
1675 } 1562 }
1676 } 1563 }
1677 1564
1678 // Process all accessible screens recursively 1565 result = [tool: toolDef]
1679 def processedScreens = [] as Set<String> 1566 ]]></script>
1680 ec.logger.info("list#Tools: Starting recursive processing from ${allScreens.size()} base screens") 1567 </actions>
1681 for (screenPath in allScreens) { 1568 </service>
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 1569
1688 // Add standard MCP protocol methods that clients can discover 1570 <service verb="list" noun="Tools" authenticate="false" allow-remote="true" transaction-timeout="60">
1689 def standardMcpMethods = [ 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
1582
1583 ExecutionContext ec = context.ec
1584
1585 // Static list of Discovery Tools
1586 def tools = [
1690 [ 1587 [
1691 name: "tools/list", 1588 name: "moqui_browse_screens",
1692 title: "List Available Tools", 1589 title: "Browse Screens",
1693 description: "Get a list of all available MCP tools including Moqui services and screens", 1590 description: "Browse the Moqui screen hierarchy. Use this to discover capabilities. Input 'path' (empty for root).",
1694 inputSchema: [ 1591 inputSchema: [
1695 type: "object", 1592 type: "object",
1696 properties: [ 1593 properties: [
1697 cursor: [ 1594 path: [type: "string", description: "Path to browse (e.g. 'moqui_PopCommerce')"]
1698 type: "string",
1699 description: "Pagination cursor for large tool lists"
1700 ] 1595 ]
1701 ],
1702 required: []
1703 ] 1596 ]
1704 ], 1597 ],
1705 [ 1598 [
1706 name: "tools/call", 1599 name: "moqui_search_screens",
1707 title: "Execute Tool", 1600 title: "Search Screens",
1708 description: "Execute a specific MCP tool by name with parameters", 1601 description: "Search for screens/functionality by name.",
1709 inputSchema: [ 1602 inputSchema: [
1710 type: "object", 1603 type: "object",
1711 properties: [ 1604 properties: [
1712 name: [ 1605 query: [type: "string", description: "Search query"]
1713 type: "string",
1714 description: "Name of the tool to execute"
1715 ], 1606 ],
1716 arguments: [ 1607 required: ["query"]
1717 type: "object",
1718 description: "Parameters to pass to the tool"
1719 ]
1720 ],
1721 required: ["name"]
1722 ] 1608 ]
1723 ], 1609 ],
1724 [ 1610 [
1725 name: "resources/list", 1611 name: "moqui_get_screen_details",
1726 title: "List Resources", 1612 title: "Get Screen Details",
1727 description: "Get a list of available MCP resources (Moqui entities)", 1613 description: "Get input schema and details for a specific tool/screen.",
1728 inputSchema: [ 1614 inputSchema: [
1729 type: "object", 1615 type: "object",
1730 properties: [ 1616 properties: [
1731 cursor: [ 1617 name: [type: "string", description: "Tool name"]
1732 type: "string",
1733 description: "Pagination cursor for large resource lists"
1734 ]
1735 ], 1618 ],
1736 required: [] 1619 required: ["name"]
1737 ] 1620 ]
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 ] 1621 ]
1750 ],
1751 required: ["uri"]
1752 ] 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: []]
1753 ], 1631 ],
1754 [ 1632 [
1755 name: "ping", 1633 name: "tools/call",
1756 title: "Ping Server", 1634 title: "Execute Tool",
1757 description: "Test connectivity to the MCP server and get session info", 1635 description: "Execute a specific MCP tool",
1758 inputSchema: [ 1636 inputSchema: [
1759 type: "object", 1637 type: "object",
1760 properties: [:], 1638 properties: [
1761 required: [] 1639 name: [type: "string"],
1640 arguments: [type: "object"]
1641 ],
1642 required: ["name"]
1762 ] 1643 ]
1763 ] 1644 ]
1764 ] 1645 ]
1765 1646
1766 tools.addAll(0, standardMcpMethods) // Add at beginning so they appear on first page 1647 tools.addAll(standardMcpMethods)
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 }
1773 }
1774
1775 // Pagination (same as original)
1776 def pageSize = 50
1777 def startIndex = cursor ? Integer.parseInt(cursor) : 0
1778 def endIndex = Math.min(startIndex + pageSize, tools.size())
1779 def paginatedTools = tools.subList(startIndex, endIndex)
1780
1781 result = [tools: paginatedTools]
1782 if (endIndex < tools.size()) {
1783 result.nextCursor = String.valueOf(endIndex)
1784 }
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 }