Skip to content
Toggle navigation
Toggle navigation
This project
Loading...
Sign in
Ean Schuessler
/
mo-mcp
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Commits
Issue Boards
Files
Commits
Network
Compare
Branches
Tags
cb2032a3
authored
2025-12-07 18:22:12 -0600
by
Ean Schuessler
Browse Files
Options
Browse Files
Tag
Download
Email Patches
Plain Diff
Fix Groovy syntax error in list#Tools service - correct indentation and missing closing brace
1 parent
ac9f2945
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
135 additions
and
371 deletions
service/McpServices.xml
service/McpServices.xml
View file @
cb2032a
...
...
@@ -114,11 +114,12 @@
</actions>
</service>
<service
verb=
"mcp"
noun=
"Tools
List"
authenticate=
"false"
allow-remote=
"true"
transaction-timeout=
"6
0"
>
<description>
Handle MCP tools/
list request with admin discovery but user permission filtering
</description>
<service
verb=
"mcp"
noun=
"Tools
Call"
authenticate=
"true"
allow-remote=
"true"
transaction-timeout=
"30
0"
>
<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())
}
// 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"
]
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
+
')'
:
''}")
if (protocolMethodMappings.containsKey(name)) {
ec.logger.info("MCP ToolsCall: Routing protocol method ${name} to ${protocolMethodMappings[name]}")
def targetServiceName = protocolMethodMappings[name]
//
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"
]
def jsonSchemaType = typeMap[paramType] ?: "string"
tool.inputSchema.properties[paramName] = [
type: jsonSchemaType,
description: paramDesc
]
if (paramNode?.attribute('required') == "true") {
tool.inputSchema.required
<
< paramName
}
}
return
tool
}
catch
(Exception
e)
{
ec.logger.warn("Error
converting
service
${serviceName}
to
tool:
${e.message}")
return
null
// 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
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}")
}
//
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:
[]
]
]
]
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()
]
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Convert result to MCP format
def content = []
if (serviceResult?.result) {
content << [
type: "text",
text: new groovy.json.JsonBuilder(serviceResult.result).toString()
]
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}")
result.result = [
content: content,
isError: false
]
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,28 +222,31 @@ 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 ?: [:]
// Use requested render mode from arguments, default to html
def renderMode = screenParams.remove('renderMode') ?: "html"
def serviceCallParams = [screenPath: screenPath, parameters: screenParams, renderMode: renderMode, sessionId: sessionId]
if (subscreenName) {
serviceCallParams.subscreenName = subscreenName
}
// Now call the screen tool with proper user context
def screenParams = arguments ?: [:]
// Use requested render mode from arguments, default to html
def renderMode = screenParams.remove('renderMode') ?: "html"
def serviceCallParams = [screenPath: screenPath, parameters: screenParams, renderMode: renderMode, sessionId: sessionId]
if (subscreenName) {
serviceCallParams.subscreenName = subscreenName
}
serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters(serviceCallParams)
.call()
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool")
.parameters(serviceCallParams)
.call()
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Convert result to MCP format
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.t
ext ?: serviceResult.result.t
oString() ?: "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,25 +1872,16 @@ 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
def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView")
.condition("artifactTypeEnumId", "AT_XML_SCREEN")
.useCache(true)
.disableAuthz()
.list()
allScreens = aacvList.collect { it.artifactName } as Set
<String>
} finally {
if (adminUserInfo != null) {
ec.user.popUser()
}
}
// 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>
// Helper function to convert screen path to MCP tool name
def screenPathToToolName = { screenPath ->
...
...
@@ -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_" + parent
CleanPath.replace('/', '_')
+ "." + subscreenName
return "screen_" + parent
ScreenName
+ "." + 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
...
...
Please
register
or
sign in
to post a comment