1bb29d0c by Ean Schuessler

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

1 parent 1f2291ad
......@@ -582,12 +582,12 @@ try {
// Validate Accept header per MCP 2025-11-25 spec requirement #2
// Client MUST include Accept header listing both application/json and text/event-stream
if (!acceptHeader || !(acceptHeader.contains("application/json") || acceptHeader.contains("text/event-stream"))) {
if (!acceptHeader || !(acceptHeader.contains("application/json") && acceptHeader.contains("text/event-stream"))) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Accept header must include application/json and/or text/event-stream per MCP 2025-11-25 spec"],
error: [code: -32600, message: "Accept header must include both application/json and text/event-stream per MCP 2025-11-25 spec"],
id: null
]))
return
......
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package org.moqui.mcp
import org.moqui.context.ExecutionContext
import org.moqui.util.MNode
class McpUtils {
/**
* Convert a Moqui screen path to an MCP tool name.
* Preserves case to ensure reversibility.
* Format: moqui_<Component>_<Path_Parts>
* Example: component://PopCommerce/screen/PopCommerceAdmin/Catalog.xml -> moqui_PopCommerce_PopCommerceAdmin_Catalog
*/
static String getToolName(String screenPath) {
if (!screenPath) return null
// Strip component:// prefix and .xml suffix
String cleanPath = screenPath
if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
List<String> parts = cleanPath.split('/').toList()
// Remove 'screen' if it's the second part (standard structure: component/screen/...)
if (parts.size() > 1 && parts[1] == "screen") {
parts.remove(1)
}
// Join with underscores and prefix
return "moqui_" + parts.join('_')
}
......@@ -37,20 +46,46 @@ class McpUtils {
String cleanName = toolName.substring(6) // Remove moqui_
List<String> parts = cleanName.split('_').toList()
if (parts.size() < 1) return null
String component = parts[0]
// If there's only one part (e.g. moqui_MyComponent), it might be a root or invalid
// But usually we expect at least component and screen
if (parts.size() == 1) {
// Fallback for component roots? unlikely to be a valid screen path without 'screen' dir
return "component://${component}/screen/${component}.xml"
}
// Re-insert 'screen' directory which is standard
String path = parts.subList(1, parts.size()).join('/')
return "component://${component}/screen/${path}.xml"
}
/**
* Decodes a tool name into a screen path and potential subscreen path by walking the component structure.
* This handles cases where a single XML file contains multiple nested subscreens.
*/
static Map decodeToolName(String toolName, ExecutionContext ec) {
if (!toolName || !toolName.startsWith("moqui_")) return [:]
String cleanName = toolName.substring(6) // Remove moqui_
List<String> parts = cleanName.split('_').toList()
String component = parts[0]
String currentPath = "component://${component}/screen"
List<String> subNameParts = []
// Walk down the parts to find where the XML file ends and subscreens begin
for (int i = 1; i < parts.size(); i++) {
String part = parts[i]
String nextPath = "${currentPath}/${part}"
if (ec.resource.getLocationReference(nextPath + ".xml").getExists()) {
currentPath = nextPath
subNameParts = [] // Reset subscreens if we found a deeper file
} else {
subNameParts << part
}
}
return [
screenPath: currentPath + ".xml",
subscreenName: subNameParts ? subNameParts.join("_") : null
]
}
}
......