1bb29d0c by Ean Schuessler

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

1 parent 1f2291ad
...@@ -582,12 +582,12 @@ try { ...@@ -582,12 +582,12 @@ try {
582 582
583 // Validate Accept header per MCP 2025-11-25 spec requirement #2 583 // Validate Accept header per MCP 2025-11-25 spec requirement #2
584 // Client MUST include Accept header listing both application/json and text/event-stream 584 // Client MUST include Accept header listing both application/json and text/event-stream
585 if (!acceptHeader || !(acceptHeader.contains("application/json") || acceptHeader.contains("text/event-stream"))) { 585 if (!acceptHeader || !(acceptHeader.contains("application/json") && acceptHeader.contains("text/event-stream"))) {
586 response.setStatus(HttpServletResponse.SC_BAD_REQUEST) 586 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
587 response.setContentType("application/json") 587 response.setContentType("application/json")
588 response.writer.write(JsonOutput.toJson([ 588 response.writer.write(JsonOutput.toJson([
589 jsonrpc: "2.0", 589 jsonrpc: "2.0",
590 error: [code: -32600, message: "Accept header must include application/json and/or text/event-stream per MCP 2025-11-25 spec"], 590 error: [code: -32600, message: "Accept header must include both application/json and text/event-stream per MCP 2025-11-25 spec"],
591 id: null 591 id: null
592 ])) 592 ]))
593 return 593 return
......
1 /*
2 * This software is in the public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 *
5 * To the extent possible under law, author(s) have dedicated all
6 * copyright and related and neighboring rights to this software to the
7 * public domain worldwide. This software is distributed without any
8 * warranty.
9 *
10 * You should have received a copy of the CC0 Public Domain Dedication
11 * along with this software (see the LICENSE.md file). If not, see
12 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13 */
1 package org.moqui.mcp 14 package org.moqui.mcp
2 15
16 import org.moqui.context.ExecutionContext
3 import org.moqui.util.MNode 17 import org.moqui.util.MNode
4 18
5 class McpUtils { 19 class McpUtils {
6 /** 20 /**
7 * Convert a Moqui screen path to an MCP tool name. 21 * Convert a Moqui screen path to an MCP tool name.
8 * Preserves case to ensure reversibility.
9 * Format: moqui_<Component>_<Path_Parts> 22 * Format: moqui_<Component>_<Path_Parts>
10 * Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog 23 * Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog
11 */ 24 */
12 static String getToolName(String screenPath) { 25 static String getToolName(String screenPath) {
13 if (!screenPath) return null 26 if (!screenPath) return null
14 27
15 // Strip component:// prefix and .xml suffix
16 String cleanPath = screenPath 28 String cleanPath = screenPath
17 if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12) 29 if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
18 if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) 30 if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
19 31
20 List<String> parts = cleanPath.split('/').toList() 32 List<String> parts = cleanPath.split('/').toList()
21
22 // Remove 'screen' if it's the second part (standard structure: component/screen/...)
23 if (parts.size() > 1 && parts[1] == "screen") { 33 if (parts.size() > 1 && parts[1] == "screen") {
24 parts.remove(1) 34 parts.remove(1)
25 } 35 }
26 36
27 // Join with underscores and prefix
28 return "moqui_" + parts.join('_') 37 return "moqui_" + parts.join('_')
29 } 38 }
30 39
...@@ -37,20 +46,46 @@ class McpUtils { ...@@ -37,20 +46,46 @@ class McpUtils {
37 46
38 String cleanName = toolName.substring(6) // Remove moqui_ 47 String cleanName = toolName.substring(6) // Remove moqui_
39 List<String> parts = cleanName.split('_').toList() 48 List<String> parts = cleanName.split('_').toList()
40
41 if (parts.size() < 1) return null 49 if (parts.size() < 1) return null
42 50
43 String component = parts[0] 51 String component = parts[0]
44
45 // If there's only one part (e.g. moqui_MyComponent), it might be a root or invalid
46 // But usually we expect at least component and screen
47 if (parts.size() == 1) { 52 if (parts.size() == 1) {
48 // Fallback for component roots? unlikely to be a valid screen path without 'screen' dir
49 return "component://${component}/screen/${component}.xml" 53 return "component://${component}/screen/${component}.xml"
50 } 54 }
51 55
52 // Re-insert 'screen' directory which is standard
53 String path = parts.subList(1, parts.size()).join('/') 56 String path = parts.subList(1, parts.size()).join('/')
54 return "component://${component}/screen/${path}.xml" 57 return "component://${component}/screen/${path}.xml"
55 } 58 }
59
60 /**
61 * Decodes a tool name into a screen path and potential subscreen path by walking the component structure.
62 * This handles cases where a single XML file contains multiple nested subscreens.
63 */
64 static Map decodeToolName(String toolName, ExecutionContext ec) {
65 if (!toolName || !toolName.startsWith("moqui_")) return [:]
66
67 String cleanName = toolName.substring(6) // Remove moqui_
68 List<String> parts = cleanName.split('_').toList()
69 String component = parts[0]
70
71 String currentPath = "component://${component}/screen"
72 List<String> subNameParts = []
73
74 // Walk down the parts to find where the XML file ends and subscreens begin
75 for (int i = 1; i < parts.size(); i++) {
76 String part = parts[i]
77 String nextPath = "${currentPath}/${part}"
78 if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) {
79 currentPath = nextPath
80 subNameParts = [] // Reset subscreens if we found a deeper file
81 } else {
82 subNameParts << part
83 }
84 }
85
86 return [
87 screenPath: currentPath + ".xml",
88 subscreenName: subNameParts ? subNameParts.join("_") : null
89 ]
90 }
56 } 91 }
......