Implement ergonomic MCP tool improvements: tiered discovery (Browse, Search, Det…
…ails), semantic naming, and McpUtils refactoring.
Showing
2 changed files
with
432 additions
and
513 deletions
| ... | @@ -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 | } |
-
Please register or sign in to post a comment