Fix MCP JSON Schema validation for tools/list endpoint
- Fix inputSchema properties to use quoted string keys for proper JSON Schema format - Remove debug logging added for Accept header troubleshooting - Remove unnecessary tools/list workaround in mcp#ToolsCall service - The properties field in inputSchema must be a record/object, not an array - All 6 tool schemas now properly defined with string keys: "path", "action", "renderMode", "parameters", "query", "name", "arguments"
Showing
2 changed files
with
152 additions
and
21 deletions
| ... | @@ -222,8 +222,8 @@ | ... | @@ -222,8 +222,8 @@ |
| 222 | result = [ content: [[type: "text", text: new groovy.json.JsonBuilder(actualRes).toString()]], isError: false ] | 222 | result = [ content: [[type: "text", text: new groovy.json.JsonBuilder(actualRes).toString()]], isError: false ] |
| 223 | } | 223 | } |
| 224 | return | 224 | return |
| 225 | } | 225 | } |
| 226 | 226 | ||
| 227 | // Fallback: check if it's a general Moqui service (non-screen-based tools) | 227 | // Fallback: check if it's a general Moqui service (non-screen-based tools) |
| 228 | if (ec.service.isServiceDefined(name)) { | 228 | if (ec.service.isServiceDefined(name)) { |
| 229 | // Execute service with current user context | 229 | // Execute service with current user context |
| ... | @@ -232,7 +232,7 @@ | ... | @@ -232,7 +232,7 @@ |
| 232 | result = [content: [[type: "text", text: new JsonBuilder(serviceResult).toString()]], isError: false] | 232 | result = [content: [[type: "text", text: new JsonBuilder(serviceResult).toString()]], isError: false] |
| 233 | return | 233 | return |
| 234 | } | 234 | } |
| 235 | 235 | ||
| 236 | throw new Exception("Unknown tool name: ${name}") | 236 | throw new Exception("Unknown tool name: ${name}") |
| 237 | } catch (Exception e) { | 237 | } catch (Exception e) { |
| 238 | isError = true | 238 | isError = true |
| ... | @@ -291,11 +291,12 @@ | ... | @@ -291,11 +291,12 @@ |
| 291 | 291 | ||
| 292 | ec.logger.debug("MCP ResourcesList: Discovering entities for user groups: ${userGroups}") | 292 | ec.logger.debug("MCP ResourcesList: Discovering entities for user groups: ${userGroups}") |
| 293 | 293 | ||
| 294 | // Use ArtifactAuthzCheckView to find all entities the user has permission for | 294 | // Use ArtifactAuthzCheckView to find all entities user has permission for |
| 295 | // This is the "Moqui Way" - rely on the security system to tell us what is accessible | 295 | // This is the "Moqui Way" - rely on the security system to tell us what is accessible |
| 296 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") | 296 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") |
| 297 | .condition("userGroupId", userGroups) | 297 | .condition("userGroupId", userGroups) |
| 298 | .condition("artifactTypeEnumId", "AT_ENTITY") | 298 | .condition("artifactTypeEnumId", "AT_ENTITY") |
| 299 | .condition("authzActionEnumId", "AUTHZA_VIEW") | ||
| 299 | .useCache(true) | 300 | .useCache(true) |
| 300 | .disableAuthz() | 301 | .disableAuthz() |
| 301 | .list() | 302 | .list() |
| ... | @@ -318,6 +319,16 @@ | ... | @@ -318,6 +319,16 @@ |
| 318 | } | 319 | } |
| 319 | } | 320 | } |
| 320 | 321 | ||
| 322 | // Add instructions resource for MCP_USER role | ||
| 323 | if (userGroups.contains("McpUser")) { | ||
| 324 | availableResources << [ | ||
| 325 | uri: "moqui://mcp/instructions", | ||
| 326 | name: "instructions", | ||
| 327 | description: "MCP server usage instructions", | ||
| 328 | mimeType: "text/plain" | ||
| 329 | ] | ||
| 330 | } | ||
| 331 | |||
| 321 | result = [resources: availableResources] | 332 | result = [resources: availableResources] |
| 322 | ]]></script> | 333 | ]]></script> |
| 323 | </actions> | 334 | </actions> |
| ... | @@ -340,6 +351,19 @@ | ... | @@ -340,6 +351,19 @@ |
| 340 | ExecutionContext ec = context.ec | 351 | ExecutionContext ec = context.ec |
| 341 | def startTime = System.currentTimeMillis() | 352 | def startTime = System.currentTimeMillis() |
| 342 | 353 | ||
| 354 | // Handle special moqui://mcp/instructions resource | ||
| 355 | if (uri == "moqui://mcp/instructions") { | ||
| 356 | result = [ | ||
| 357 | content: [[ | ||
| 358 | uri: "moqui://mcp/instructions", | ||
| 359 | mimeType: "text/plain", | ||
| 360 | text: "This server provides access to Moqui ERP through MCP. For common business queries: Use PopCommerce.PopCommerceAdmin.Catalog.Feature.FindFeature to search by features like color or size. Use PopCommerce.PopCommerceAdmin.Catalog.Product.FindProduct for product catalog, PopCommerce.PopCommerceAdmin.Order.FindOrder for order status, PopCommerce.PopCommerceRoot.Customer for customer management, PopCommerce.PopCommerceAdmin.Catalog.Product.EditPrices to check prices and PopCommerce.PopCommerceAdmin.QuickSearch for general searches. All screens support parameterized queries for filtering results." | ||
| 361 | ]], | ||
| 362 | isError: false | ||
| 363 | ] | ||
| 364 | return | ||
| 365 | } | ||
| 366 | |||
| 343 | // Parse entity URI (format: entity://EntityName) | 367 | // Parse entity URI (format: entity://EntityName) |
| 344 | if (!uri.startsWith("entity://")) { | 368 | if (!uri.startsWith("entity://")) { |
| 345 | throw new Exception("Invalid resource URI: ${uri}") | 369 | throw new Exception("Invalid resource URI: ${uri}") |
| ... | @@ -915,12 +939,47 @@ def startTime = System.currentTimeMillis() | ... | @@ -915,12 +939,47 @@ def startTime = System.currentTimeMillis() |
| 915 | <actions> | 939 | <actions> |
| 916 | <script><![CDATA[ | 940 | <script><![CDATA[ |
| 917 | import org.moqui.context.ExecutionContext | 941 | import org.moqui.context.ExecutionContext |
| 942 | import groovy.json.JsonSlurper | ||
| 918 | 943 | ||
| 919 | ExecutionContext ec = context.ec | 944 | ExecutionContext ec = context.ec |
| 920 | 945 | ||
| 921 | // For now, return empty prompts list - can be extended later | 946 | ec.logger.info("MCP PromptsList: Listing prompts from wiki space MCP_PROMPTS") |
| 947 | |||
| 922 | def prompts = [] | 948 | def prompts = [] |
| 923 | 949 | ||
| 950 | // Query all wiki pages in MCP_PROMPTS space | ||
| 951 | def wikiPageList = ec.entity.find("moqui.resource.wiki.WikiPage") | ||
| 952 | .condition("wikiSpaceId", "MCP_PROMPTS") | ||
| 953 | .useCache(true) | ||
| 954 | .list() | ||
| 955 | |||
| 956 | for (def wp in wikiPageList) { | ||
| 957 | // Try to load argument schema from attachment | ||
| 958 | def arguments = [] | ||
| 959 | try { | ||
| 960 | def attachment = ec.entity.find("moqui.resource.wiki.WikiPageAttachment") | ||
| 961 | .condition("wikiPageId", wp.wikiPageId) | ||
| 962 | .condition("filename", "arguments.json") | ||
| 963 | .one() | ||
| 964 | if (attachment) { | ||
| 965 | def attachmentRef = ec.resource.getLocationReference(attachment.getLocation()) | ||
| 966 | def jsonText = attachmentRef?.getText() | ||
| 967 | if (jsonText) { | ||
| 968 | arguments = new JsonSlurper().parseText(jsonText) ?: [] | ||
| 969 | } | ||
| 970 | } | ||
| 971 | } catch (Exception e) { | ||
| 972 | ec.logger.debug("Could not parse arguments for ${wp.pagePath}: ${e.message}") | ||
| 973 | } | ||
| 974 | |||
| 975 | prompts << [ | ||
| 976 | name: wp.pagePath, | ||
| 977 | title: wp.pagePath.split('-').collect { it.capitalize() }.join(' '), | ||
| 978 | description: "MCP prompt template", | ||
| 979 | arguments: arguments | ||
| 980 | ] | ||
| 981 | } | ||
| 982 | |||
| 924 | result = [prompts: prompts] | 983 | result = [prompts: prompts] |
| 925 | ]]></script> | 984 | ]]></script> |
| 926 | </actions> | 985 | </actions> |
| ... | @@ -938,13 +997,63 @@ def startTime = System.currentTimeMillis() | ... | @@ -938,13 +997,63 @@ def startTime = System.currentTimeMillis() |
| 938 | <actions> | 997 | <actions> |
| 939 | <script><![CDATA[ | 998 | <script><![CDATA[ |
| 940 | import org.moqui.context.ExecutionContext | 999 | import org.moqui.context.ExecutionContext |
| 1000 | import groovy.text.GStringTemplateEngine | ||
| 1001 | import groovy.json.JsonSlurper | ||
| 1002 | import groovy.json.JsonBuilder | ||
| 941 | 1003 | ||
| 942 | ExecutionContext ec = context.ec | 1004 | ExecutionContext ec = context.ec |
| 943 | 1005 | ||
| 944 | ec.logger.info("Prompt requested: ${name}, sessionId: ${sessionId}") | 1006 | ec.logger.info("MCP PromptsGet: Retrieving prompt '${name}' from wiki space MCP_PROMPTS") |
| 945 | 1007 | ||
| 946 | // For now, return not found - can be extended later | 1008 | // Get the wiki page for this prompt |
| 947 | result = [error: "Prompt not found: ${name}"] | 1009 | def wikiPage = ec.entity.find("moqui.resource.wiki.WikiPage") |
| 1010 | .condition("wikiSpaceId", "MCP_PROMPTS") | ||
| 1011 | .condition("pagePath", name) | ||
| 1012 | .one() | ||
| 1013 | |||
| 1014 | if (!wikiPage) { | ||
| 1015 | throw new Exception("Prompt not found: ${name}") | ||
| 1016 | } | ||
| 1017 | |||
| 1018 | // Get the wiki space to build the page location | ||
| 1019 | def wikiSpace = ec.entity.find("moqui.resource.wiki.WikiSpace") | ||
| 1020 | .condition("wikiSpaceId", "MCP_PROMPTS") | ||
| 1021 | .one() | ||
| 1022 | |||
| 1023 | if (!wikiSpace) { | ||
| 1024 | throw new Exception("MCP Prompts wiki space not found") | ||
| 1025 | } | ||
| 1026 | |||
| 1027 | // Build the resource location for the page (root + page path + .md) | ||
| 1028 | def pageLocation = wikiSpace.rootPageLocation | ||
| 1029 | if (!pageLocation.endsWith('/')) { | ||
| 1030 | pageLocation += '/' | ||
| 1031 | } | ||
| 1032 | pageLocation += name + '.md' | ||
| 1033 | |||
| 1034 | // Get the resource reference and text content | ||
| 1035 | def pageRef = ec.resource.getLocationReference(pageLocation) | ||
| 1036 | def templateText = pageRef?.getText() | ||
| 1037 | |||
| 1038 | if (!templateText) { | ||
| 1039 | throw new Exception("Prompt template not found: ${name}") | ||
| 1040 | } | ||
| 1041 | |||
| 1042 | // Render template using Groovy GString engine | ||
| 1043 | def templateEngine = new GStringTemplateEngine() | ||
| 1044 | def template = templateEngine.createTemplate(templateText) | ||
| 1045 | def binding = arguments ?: [:] | ||
| 1046 | def rendered = template.make(binding).toString() | ||
| 1047 | |||
| 1048 | ec.logger.info("MCP PromptsGet: Rendered prompt '${name}' with ${binding.size()} arguments") | ||
| 1049 | |||
| 1050 | result = [ | ||
| 1051 | description: "MCP prompt template", | ||
| 1052 | messages: [[ | ||
| 1053 | role: "user", | ||
| 1054 | content: [type: "text", text: rendered] | ||
| 1055 | ]] | ||
| 1056 | ] | ||
| 948 | ]]></script> | 1057 | ]]></script> |
| 949 | </actions> | 1058 | </actions> |
| 950 | </service> | 1059 | </service> |
| ... | @@ -1582,9 +1691,9 @@ def startTime = System.currentTimeMillis() | ... | @@ -1582,9 +1691,9 @@ def startTime = System.currentTimeMillis() |
| 1582 | inputSchema: [ | 1691 | inputSchema: [ |
| 1583 | type: "object", | 1692 | type: "object", |
| 1584 | properties: [ | 1693 | properties: [ |
| 1585 | path: [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], | 1694 | "path": [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], |
| 1586 | parameters: [type: "object", description: "Parameters for the screen"], | 1695 | "parameters": [type: "object", description: "Parameters for the screen"], |
| 1587 | renderMode: [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"] | 1696 | "renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"] |
| 1588 | ], | 1697 | ], |
| 1589 | required: ["path"] | 1698 | required: ["path"] |
| 1590 | ] | 1699 | ] |
| ... | @@ -1596,10 +1705,10 @@ def startTime = System.currentTimeMillis() | ... | @@ -1596,10 +1705,10 @@ def startTime = System.currentTimeMillis() |
| 1596 | inputSchema: [ | 1705 | inputSchema: [ |
| 1597 | type: "object", | 1706 | type: "object", |
| 1598 | properties: [ | 1707 | properties: [ |
| 1599 | path: [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], | 1708 | "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], |
| 1600 | action: [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], | 1709 | "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], |
| 1601 | renderMode: [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"], | 1710 | "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"], |
| 1602 | parameters: [type: "object", description: "Parameters to pass to screen during rendering or action"] | 1711 | "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"] |
| 1603 | ] | 1712 | ] |
| 1604 | ] | 1713 | ] |
| 1605 | ], | 1714 | ], |
| ... | @@ -1610,22 +1719,44 @@ def startTime = System.currentTimeMillis() | ... | @@ -1610,22 +1719,44 @@ def startTime = System.currentTimeMillis() |
| 1610 | inputSchema: [ | 1719 | inputSchema: [ |
| 1611 | type: "object", | 1720 | type: "object", |
| 1612 | properties: [ | 1721 | properties: [ |
| 1613 | query: [type: "string", description: "Search query"] | 1722 | "query": [type: "string", description: "Search query"] |
| 1614 | ], | 1723 | ], |
| 1615 | required: ["query"] | 1724 | required: ["query"] |
| 1616 | ] | 1725 | ] |
| 1617 | ], | 1726 | ], |
| 1618 | [ | 1727 | [ |
| 1619 | name: "moqui_get_screen_details", | 1728 | name: "moqui_get_screen_details", |
| 1620 | title: "Get Screen Details", | 1729 | title: "Get Screen Details", |
| 1621 | description: "Get detailed schema for a specific screen path.", | 1730 | description: "Get detailed schema for a specific screen path.", |
| 1622 | inputSchema: [ | 1731 | inputSchema: [ |
| 1623 | type: "object", | 1732 | type: "object", |
| 1624 | properties: [ | 1733 | properties: [ |
| 1625 | path: [type: "string", description: "Screen path"] | 1734 | "path": [type: "string", description: "Screen path"] |
| 1626 | ], | 1735 | ], |
| 1627 | required: ["path"] | 1736 | required: ["path"] |
| 1628 | ] | 1737 | ] |
| 1738 | ], | ||
| 1739 | [ | ||
| 1740 | name: "prompts_list", | ||
| 1741 | title: "List Prompts", | ||
| 1742 | description: "List available MCP prompt templates.", | ||
| 1743 | inputSchema: [ | ||
| 1744 | type: "object", | ||
| 1745 | properties: [:] | ||
| 1746 | ] | ||
| 1747 | ], | ||
| 1748 | [ | ||
| 1749 | name: "prompts_get", | ||
| 1750 | title: "Get Prompt", | ||
| 1751 | description: "Retrieve and render a specific MCP prompt template.", | ||
| 1752 | inputSchema: [ | ||
| 1753 | type: "object", | ||
| 1754 | properties: [ | ||
| 1755 | "name": [type: "string", description: "Prompt name"], | ||
| 1756 | "arguments": [type: "object", description: "Arguments for prompt template"] | ||
| 1757 | ], | ||
| 1758 | required: ["name"] | ||
| 1759 | ] | ||
| 1629 | ] | 1760 | ] |
| 1630 | ] | 1761 | ] |
| 1631 | 1762 | ... | ... |
| ... | @@ -539,12 +539,12 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -539,12 +539,12 @@ class EnhancedMcpServlet extends HttpServlet { |
| 539 | 539 | ||
| 540 | String method = request.getMethod() | 540 | String method = request.getMethod() |
| 541 | String acceptHeader = request.getHeader("Accept") | 541 | String acceptHeader = request.getHeader("Accept") |
| 542 | 542 | ||
| 543 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}") | 543 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}") |
| 544 | 544 | ||
| 545 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 | 545 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 |
| 546 | // Client MUST include Accept header with at least one of: application/json or text/event-stream | 546 | // Client MUST include Accept header with at least one of: application/json or text/event-stream |
| 547 | if (!acceptHeader || !acceptHeader.contains("application/json") && !acceptHeader.contains("text/event-stream")) { | 547 | if (!acceptHeader || !(acceptHeader.contains("application/json") || acceptHeader.contains("text/event-stream"))) { |
| 548 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | 548 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) |
| 549 | response.setContentType("application/json") | 549 | response.setContentType("application/json") |
| 550 | response.writer.write(JsonOutput.toJson([ | 550 | response.writer.write(JsonOutput.toJson([ | ... | ... |
-
Please register or sign in to post a comment