cb2032a3 by Ean Schuessler

Fix Groovy syntax error in list#Tools service - correct indentation and missing closing brace

1 parent ac9f2945
......@@ -114,11 +114,12 @@
</actions>
</service>
<service verb="mcp" noun="ToolsList" authenticate="false" allow-remote="true" transaction-timeout="60">
<description>Handle MCP tools/list request with admin discovery but user permission filtering</description>
<service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300">
<description>Handle MCP tools/call request with direct Moqui service execution</description>
<in-parameters>
<parameter name="sessionId"/>
<parameter name="cursor"/>
<parameter name="sessionId" required="false"/>
<parameter name="name" required="true"/>
<parameter name="arguments" type="Map"/>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
......@@ -126,331 +127,76 @@
<actions>
<script><![CDATA[
import org.moqui.context.ExecutionContext
import java.util.UUID
import org.moqui.impl.context.UserFacadeImpl.UserInfo
import groovy.json.JsonBuilder
ExecutionContext ec = context.ec
// Permissions are handled by Moqui's artifact authorization system
// Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
// Session validation and activity management moved to servlet layer
// Services are now stateless - only receive sessionId for context
// Start timing for execution metrics
def startTime = System.currentTimeMillis()
// Store original user context before switching to ADMIN
def originalUsername = ec.user.username
def originalUserId = ec.user.userId
def userGroups = ec.user.getUserGroupIdSet().collect { it }
// Get user's accessible services using Moqui's optimized ArtifactAuthzCheckView
def userAccessibleServices = null as Set<String>
adminUserInfo = null
try {
adminUserInfo = ec.user.pushUser("ADMIN")
def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
.condition("userGroupId", userGroups)
.condition("artifactTypeEnumId", "AT_SERVICE")
.useCache(true)
.disableAuthz()
.list()
userAccessibleServices = aacvList.collect { it.artifactName } as Set<String>
} finally {
if (adminUserInfo != null) {
ec.user.popUser()
}
}
// Helper function to check if user has permission to a service
def userHasPermission = { serviceName ->
// Use pre-computed accessible services set for O(1) lookup
return userAccessibleServices != null && userAccessibleServices.contains(serviceName.toString())
}
try {
def availableTools = []
ec.logger.info("MCP ToolsList: DEBUG - Starting tools list generation")
// Get only services user has access to via artifact groups
def accessibleServiceNames = []
for (serviceName in userAccessibleServices) {
// Handle wildcard patterns like "McpServices.*"
if (serviceName.contains("*")) {
def pattern = serviceName.replace("*", ".*")
def allServiceNames = ec.service.getKnownServiceNames()
def matchingServices = allServiceNames.findAll { it.matches(pattern) }
// Only add services that actually exist
accessibleServiceNames.addAll(matchingServices.findAll { ec.service.isServiceDefined(it) })
} else {
// Only add if service actually exists
if (ec.service.isServiceDefined(serviceName)) {
accessibleServiceNames << serviceName
}
}
}
accessibleServiceNames = accessibleServiceNames.unique()
ec.logger.info("MCP ToolsList: Found ${accessibleServiceNames.size()} accessible services for user ${originalUsername} (${originalUserId})${sessionId ? ' (session: ' + sessionId + ')' : ''}")
// Helper function to convert service to MCP tool
def convertServiceToTool = { serviceName ->
try {
def serviceDefinition = ec.service.getServiceDefinition(serviceName)
if (!serviceDefinition) return null
def serviceNode = serviceDefinition.serviceNode
// Convert service to MCP tool format
def tool = [
name: serviceName,
title: serviceNode.first("description")?.text ?: serviceName,
description: serviceNode.first("description")?.text ?: "Moqui service: ${serviceName}",
inputSchema: [
type: "object",
properties: [:],
required: []
]
]
// Add service metadata to help LLM
if (serviceDefinition.verb && serviceDefinition.noun) {
tool.description += " (${serviceDefinition.verb}:${serviceDefinition.noun})"
}
// Convert service parameters to JSON Schema
def inParamNames = serviceDefinition.getInParameterNames()
for (paramName in inParamNames) {
def paramNode = serviceDefinition.getInParameter(paramName)
def paramDesc = paramNode.first("description")?.text ?: ""
// Add type information to description for LLM
def paramType = paramNode?.attribute('type') ?: 'String'
if (!paramDesc) {
paramDesc = "Parameter of type ${paramType}"
} else {
paramDesc += " (type: ${paramType})"
}
// Convert Moqui type to JSON Schema type
def typeMap = [
"text-short": "string",
"text-medium": "string",
"text-long": "string",
"text-very-long": "string",
"id": "string",
"id-long": "string",
"number-integer": "integer",
"number-decimal": "number",
"number-float": "number",
"date": "string",
"date-time": "string",
"date-time-nano": "string",
"boolean": "boolean",
"text-indicator": "boolean"
// Handle stubbed MCP protocol methods by routing to actual Moqui services
def protocolMethodMappings = [
"tools/list": "McpServices.list#Tools",
"tools/call": "McpServices.mcp#ToolsCall",
"resources/list": "McpServices.mcp#ResourcesList",
"resources/read": "McpServices.mcp#ResourcesRead",
"resources/subscribe": "McpServices.mcp#ResourcesSubscribe",
"resources/unsubscribe": "McpServices.mcp#ResourcesUnsubscribe",
"prompts/list": "McpServices.mcp#PromptsList",
"prompts/get": "McpServices.mcp#PromptsGet",
"ping": "McpServices.mcp#Ping"
]
def jsonSchemaType = typeMap[paramType] ?: "string"
tool.inputSchema.properties[paramName] = [
type: jsonSchemaType,
description: paramDesc
]
if (protocolMethodMappings.containsKey(name)) {
ec.logger.info("MCP ToolsCall: Routing protocol method ${name} to ${protocolMethodMappings[name]}")
def targetServiceName = protocolMethodMappings[name]
if (paramNode?.attribute('required') == "true") {
tool.inputSchema.required << paramName
}
}
// Special handling for tools/call to avoid infinite recursion
if (name == "tools/call") {
// Extract the actual tool name and arguments from arguments
def actualToolName = arguments?.name
def actualArguments = arguments?.arguments
return tool
} catch (Exception e) {
ec.logger.warn("Error converting service ${serviceName} to tool: ${e.message}")
return null
}
if (!actualToolName) {
throw new Exception("tools/call requires 'name' parameter in arguments")
}
// Add all accessible services as tools
for (serviceName in accessibleServiceNames) {
def tool = convertServiceToTool(serviceName)
if (tool) {
availableTools << tool
}
// Ensure sessionId is always passed through in arguments
if (actualArguments instanceof Map) {
actualArguments.sessionId = sessionId
} else {
actualArguments = [sessionId: sessionId]
}
// Add screen-based tools
try {
def screenToolsResult = ec.service.sync().name("McpServices.discover#ScreensAsMcpTools")
.parameters([sessionId: sessionId])
.requireNewTransaction(false) // Use current transaction
// Recursively call the actual tool
return ec.service.sync().name("McpServices.mcp#ToolsCall")
.parameters([sessionId: sessionId, name: actualToolName, arguments: actualArguments])
.call()
} else {
// For other protocol methods, call the target service with provided arguments
def serviceResult = ec.service.sync().name(targetServiceName)
.parameters(arguments ?: [:])
.call()
if (screenToolsResult?.tools) {
availableTools.addAll(screenToolsResult.tools)
ec.logger.info("MCP ToolsList: Added ${screenToolsResult.tools.size()} screen-based tools")
}
} catch (Exception e) {
ec.logger.warn("Error discovering screen tools: ${e.message}")
}
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Add standard MCP protocol methods that clients can discover (add at end so they appear on first page)
def standardMcpMethods = [
[
name: "tools/list",
title: "List Available Tools",
description: "Get a list of all available MCP tools including Moqui services and screens",
inputSchema: [
type: "object",
properties: [
cursor: [
type: "string",
description: "Pagination cursor for large tool lists"
]
],
required: []
]
],
[
name: "tools/call",
title: "Execute Tool",
description: "Execute a specific MCP tool by name with parameters",
inputSchema: [
type: "object",
properties: [
name: [
type: "string",
description: "Name of the tool to execute"
],
arguments: [
type: "object",
description: "Parameters to pass to the tool"
]
],
required: ["name"]
]
],
[
name: "resources/list",
title: "List Resources",
description: "Get a list of available MCP resources (Moqui entities)",
inputSchema: [
type: "object",
properties: [
cursor: [
type: "string",
description: "Pagination cursor for large resource lists"
]
],
required: []
]
],
[
name: "resources/read",
title: "Read Resource",
description: "Read data from a specific MCP resource (Moqui entity)",
inputSchema: [
type: "object",
properties: [
uri: [
type: "string",
description: "Resource URI to read (format: entity://EntityName)"
]
],
required: ["uri"]
]
],
[
name: "ping",
title: "Ping Server",
description: "Test connectivity to the MCP server and get session info",
inputSchema: [
type: "object",
properties: [:],
required: []
]
]
// Convert result to MCP format
def content = []
if (serviceResult?.result) {
content << [
type: "text",
text: new groovy.json.JsonBuilder(serviceResult.result).toString()
]
availableTools.addAll(standardMcpMethods)
ec.logger.info("MCP ToolsList: Added ${standardMcpMethods.size()} standard MCP protocol methods")
// Implement pagination according to MCP spec
def pageSize = 50 // Reasonable page size for tool lists
def startIndex = 0
if (cursor) {
try {
// Parse cursor to get start index (simple approach: cursor is the start index)
startIndex = Integer.parseInt(cursor)
} catch (Exception e) {
ec.logger.warn("Invalid cursor format: ${cursor}, starting from beginning")
startIndex = 0
}
}
// Get paginated subset of tools
def endIndex = Math.min(startIndex + pageSize, availableTools.size())
def paginatedTools = availableTools.subList(startIndex, endIndex)
result = [tools: paginatedTools]
// Add nextCursor if there are more tools
if (endIndex < availableTools.size()) {
result.nextCursor = String.valueOf(endIndex)
}
ec.logger.info("MCP ToolsList: Returning ${availableTools.size()} tools for user ${originalUsername}")
} finally {
// Always restore original user context
if (adminUserInfo != null) {
ec.user.popUser()
}
// Send a simple notification about tool execution
try {
def servlet = ec.web.getServletContext().getAttribute("enhancedMcpServlet")
ec.logger.info("TOOLS CALL: Got servlet reference: ${servlet != null}, sessionId: ${sessionId}")
if (servlet && sessionId) {
def notification = [
method: "notifications/tool_execution",
params: [
toolName: "tools/list",
executionTime: (System.currentTimeMillis() - startTime) / 1000.0,
success: !result?.result?.isError,
timestamp: System.currentTimeMillis()
]
result.result = [
content: content,
isError: false
]
servlet.queueNotification(sessionId, notification)
ec.logger.info("Queued tool execution notification for session ${sessionId}")
}
} catch (Exception e) {
ec.logger.warn("Failed to send tool execution notification: ${e.message}")
return
}
}
]]></script>
</actions>
</service>
<service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300">
<description>Handle MCP tools/call request with direct Moqui service execution</description>
<in-parameters>
<parameter name="sessionId" required="false"/>
<parameter name="name" required="true"/>
<parameter name="arguments" type="Map"/>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
</out-parameters>
<actions>
<script><![CDATA[
import org.moqui.context.ExecutionContext
import org.moqui.impl.context.UserFacadeImpl.UserInfo
import groovy.json.JsonBuilder
ExecutionContext ec = context.ec
// Start timing for execution metrics
def startTime = System.currentTimeMillis()
// Check if this is a screen-based tool or a service-based tool
def isScreenTool = name.startsWith("screen_")
......@@ -476,7 +222,7 @@ ec.logger.info("MCP ToolsList: Returning ${availableTools.size()} tools for user
// Regular screen path: _ -> /, prepend component://, append .xml
screenPath = "component://" + toolNameSuffix.replace('_', '/') + ".xml"
ec.logger.info("Decoded screen path for tool ${name}: ${screenPath}")
}
}
// Now call the screen tool with proper user context
def screenParams = arguments ?: [:]
......@@ -496,8 +242,11 @@ ec.logger.info("MCP ToolsList: Returning ${availableTools.size()} tools for user
def content = []
if (serviceResult?.result) {
// Handle screen execution result which has type, text, screenPath, screenUrl, executionTime
if (serviceResult.result.type == "text" && serviceResult.result.text) {
// Handle screen execution result which has content array
if (serviceResult.result.content && serviceResult.result.content instanceof List) {
// Use the content array directly from the screen execution result
content.addAll(serviceResult.result.content)
} else if (serviceResult.result.type == "text" && serviceResult.result.text) {
content << [
type: "text",
text: serviceResult.result.text
......@@ -505,7 +254,7 @@ ec.logger.info("MCP ToolsList: Returning ${availableTools.size()} tools for user
} else {
content << [
type: "html",
text: serviceResult.result.toString() ?: "Screen executed successfully"
text: serviceResult.result.text ?: serviceResult.result.toString() ?: "Screen executed successfully"
]
}
}
......@@ -742,7 +491,7 @@ try {
]
}
}
} catch (Exception e) {
ec.logger.warn("ResourcesRead: Error getting entity info for ${entityName}: ${e.message}")
// Fallback: try basic entity check
if (ec.entity.isEntityDefined(entityName)) {
......@@ -754,7 +503,6 @@ try {
allFieldInfoList: []
]
}
}
if (!entityDef) {
throw new Exception("Entity not found: ${entityName}")
......@@ -1884,8 +1632,17 @@ def startTime = System.currentTimeMillis()
}
}
// Return just the rendered screen content for MCP wrapper to handle
result = [
// Return screen result directly as content array (standard MCP flow)
def content = []
// Add execution status as first content item
content << [
type: "text",
text: "Screen execution completed for ${screenPath} in ${executionTime}s"
]
// Add screen HTML as main content
content << [
type: "html",
text: processedOutput,
screenPath: screenPath,
......@@ -1894,7 +1651,12 @@ def startTime = System.currentTimeMillis()
isError: isError
]
ec.logger.info("MCP Screen Execution: Generated URL for screen ${screenPath} in ${executionTime}s")
result = [
content: content,
isError: false
]
ec.logger.info("MCP Screen Execution: Queued result as notification for screen ${screenPath} in ${executionTime}s")
]]></script>
</actions>
</service>
......@@ -2110,26 +1872,17 @@ def startTime = System.currentTimeMillis()
try {
adminUserInfo = ec.user.pushUser("ADMIN")
// Get ALL screens (not just user accessible) - let Moqui security handle access during execution
def allScreens = [] as Set<String>
adminUserInfo = null
try {
adminUserInfo = ec.user.pushUser("ADMIN")
// Get all screens in the system
// Get screens accessible to user's groups
def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
.condition("artifactTypeEnumId", "AT_XML_SCREEN")
.condition("userGroupId", "in", userGroups)
.useCache(true)
.disableAuthz()
.list()
allScreens = aacvList.collect { it.artifactName } as Set<String>
} finally {
if (adminUserInfo != null) {
ec.user.popUser()
}
}
// Helper function to convert screen path to MCP tool name
def screenPathToToolName = { screenPath ->
// Clean Encoding: strip component:// and .xml, replace / with _
......@@ -2137,7 +1890,10 @@ def startTime = System.currentTimeMillis()
if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12)
if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4)
return "screen_" + cleanPath.replace('/', '_')
// Extract just the screen name from the path (last part after /)
def screenName = cleanPath.split('/')[-1]
return "screen_" + screenName
}
// Helper function to convert screen path to MCP tool name with subscreen support
......@@ -2148,11 +1904,14 @@ def startTime = System.currentTimeMillis()
if (parentCleanPath.startsWith("component://")) parentCleanPath = parentCleanPath.substring(12)
if (parentCleanPath.endsWith(".xml")) parentCleanPath = parentCleanPath.substring(0, parentCleanPath.length() - 4)
// Extract just the parent screen name (last part after /)
def parentScreenName = parentCleanPath.split('/')[-1]
// Extract subscreen name from the full screen path
def subscreenName = screenPath.split("/")[-1]
if (subscreenName.endsWith(".xml")) subscreenName = subscreenName.substring(0, subscreenName.length() - 4)
return "screen_" + parentCleanPath.replace('/', '_') + "." + subscreenName
return "screen_" + parentScreenName + "." + subscreenName
}
// Regular screen path conversion for main screens
......@@ -2168,12 +1927,15 @@ def startTime = System.currentTimeMillis()
if (processedScreens == null) processedScreens = [] as Set<String>
if (toolsAccumulator == null) toolsAccumulator = []
if (processedScreens.contains(screenPath)) {
ec.logger.info("list#Tools: Already processed ${screenPath}, skipping")
// Create a unique key for this specific access path (screen + parent)
def accessPathKey = screenPath + "|" + (parentScreenPath ?: "ROOT")
if (processedScreens.contains(accessPathKey)) {
ec.logger.info("list#Tools: Already processed ${screenPath} from parent ${parentScreenPath}, skipping")
return
}
processedScreens.add(screenPath)
processedScreens.add(accessPathKey)
try {
// Skip problematic patterns early
......@@ -2230,14 +1992,19 @@ def startTime = System.currentTimeMillis()
// Create tool with proper naming
def toolName
if (isSubscreen && parentToolName) {
// Use the passed hierarchical parent tool name
// Use the passed hierarchical parent tool name and append current subscreen name
def subscreenName = screenPath.split("/")[-1]
if (subscreenName.endsWith(".xml")) subscreenName = subscreenName.substring(0, subscreenName.length() - 4)
// Use dot for first level subscreens (level 1), underscore for deeper levels (level 2+)
def separator = (level == 1) ? "." : "_"
toolName = parentToolName + separator + subscreenName
ec.logger.info("list#Tools: Creating subscreen tool ${toolName} for ${screenPath} (parentToolName: ${parentToolName}, level: ${level}, separator: ${separator})")
// For level 1 subscreens, use dot notation
// For level 2+, replace the last dot with underscore and add the new subscreen name
if (level == 2) {
toolName = parentToolName + "." + subscreenName
} else {
// Replace last dot with underscore and append new subscreen name
toolName = parentToolName + "_" + subscreenName // .replaceAll('\\.[^.]*$', '_' + subscreenName)
}
ec.logger.info("list#Tools: Creating subscreen tool ${toolName} for ${screenPath} (parentToolName: ${parentToolName}, level: ${level})")
} else if (isSubscreen && parentScreenPath) {
toolName = screenPathToToolNameWithSubscreens(screenPath, parentScreenPath)
ec.logger.info("list#Tools: Creating subscreen tool ${toolName} for ${screenPath} (parent: ${parentScreenPath})")
......@@ -2293,17 +2060,14 @@ def startTime = System.currentTimeMillis()
// Fallback to checking if screenPath contains XML path
if (!actualSubScreenPath) {
if (subScreenInfo.screenPath instanceof List) {
def pathList = subScreenInfo.screenPath
for (path in pathList) {
if (path && path.toString().contains(".xml")) {
actualSubScreenPath = path.toString()
break
}
}
// This is a list of all subscreens/transitions, not a path
// Don't treat it as a path - use other methods to find location
ec.logger.debug("list#Tools: screenPath is a list, not using for path resolution for ${subScreenEntry.key}")
} else {
actualSubScreenPath = subScreenInfo.screenPath.toString()
}
}
} catch (Exception e) {
ec.logger.debug("list#Tools: Error getting screen location from subScreenInfo for ${subScreenEntry.key}: ${e.message}")
}
......@@ -2356,7 +2120,7 @@ def startTime = System.currentTimeMillis()
}
}
if (actualSubScreenPath && !processedScreens.contains(actualSubScreenPath)) {
if (actualSubScreenPath) {
processScreenWithSubscreens(actualSubScreenPath, screenPath, processedScreens, toolsAccumulator, toolName, level + 1)
} else if (!actualSubScreenPath) {
// For screens without explicit location, try automatic discovery
......