Switch to MoquiAuthFilter for authentication and improve screen rendering
- Delegate Basic Auth to MoquiAuthFilter instead of inline handling - Fix Accept header validation to use OR instead of AND condition - Enhanced JSON macros with form rendering and dot notation link paths - Add query parameter stripping for screen paths - Update MCP instructions to use dot notation for screen paths - Remove obsolete McpFilter implementation
Showing
8 changed files
with
346 additions
and
140 deletions
| ... | @@ -15,6 +15,9 @@ | ... | @@ -15,6 +15,9 @@ |
| 15 | 15 | ||
| 16 | <webapp-list> | 16 | <webapp-list> |
| 17 | <webapp name="webroot" http-port="8080"> | 17 | <webapp name="webroot" http-port="8080"> |
| 18 | <filter name="McpAuthFilter" class="org.moqui.impl.webapp.MoquiAuthFilter" async-supported="true"> | ||
| 19 | <url-pattern>/mcp/*</url-pattern> | ||
| 20 | </filter> | ||
| 18 | <servlet name="EnhancedMcpServlet" class="org.moqui.mcp.EnhancedMcpServlet" | 21 | <servlet name="EnhancedMcpServlet" class="org.moqui.mcp.EnhancedMcpServlet" |
| 19 | load-on-startup="5" async-supported="true"> | 22 | load-on-startup="5" async-supported="true"> |
| 20 | <init-param name="keepAliveIntervalSeconds" value="30"/> | 23 | <init-param name="keepAliveIntervalSeconds" value="30"/> | ... | ... |
| ... | @@ -24,6 +24,9 @@ | ... | @@ -24,6 +24,9 @@ |
| 24 | <!-- Webapp Configuration --> | 24 | <!-- Webapp Configuration --> |
| 25 | <webapp-list> | 25 | <webapp-list> |
| 26 | <webapp name="webroot"> | 26 | <webapp name="webroot"> |
| 27 | <filter name="McpAuthFilter" class="org.moqui.impl.webapp.MoquiAuthFilter" async-supported="true"> | ||
| 28 | <url-pattern>/mcp/*</url-pattern> | ||
| 29 | </filter> | ||
| 27 | <servlet name="EnhancedMcpServlet" class="org.moqui.mcp.EnhancedMcpServlet" | 30 | <servlet name="EnhancedMcpServlet" class="org.moqui.mcp.EnhancedMcpServlet" |
| 28 | load-on-startup="5" async-supported="true"> | 31 | load-on-startup="5" async-supported="true"> |
| 29 | <init-param name="keepAliveIntervalSeconds" value="30"/> | 32 | <init-param name="keepAliveIntervalSeconds" value="30"/> | ... | ... |
| 1 | <#-- | 1 | <#-- |
| 2 | Moqui JSON Optimized Macros | 2 | Moqui MCP JSON Macros |
| 3 | Renders screens in structured JSON format. | 3 | Renders screens in structured JSON format for LLM consumption. |
| 4 | --> | 4 | --> |
| 5 | 5 | ||
| 6 | <#include "DefaultScreenMacros.any.ftl"/> | 6 | <#include "DefaultScreenMacros.any.ftl"/> |
| 7 | 7 | ||
| ... | @@ -21,17 +21,198 @@ | ... | @@ -21,17 +21,198 @@ |
| 21 | <#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro> | 21 | <#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro> |
| 22 | 22 | ||
| 23 | <#-- ================ Section ================ --> | 23 | <#-- ================ Section ================ --> |
| 24 | <#macro section>{"type": "section", "name": "${.node["@name"]}", "content": ${sri.renderSection(.node["@name"])}}</#macro> | 24 | <#macro section>{"type": "section", "name": ${(.node["@name"]!"")?json_string}, "content": ${sri.renderSection(.node["@name"])}}</#macro> |
| 25 | <#macro "section-iterate">${sri.renderSection(.node["@name"])}</#macro> | ||
| 26 | <#macro "section-include">${sri.renderSectionInclude(.node)}</#macro> | ||
| 25 | 27 | ||
| 26 | <#-- ================ Containers ================ --> | 28 | <#-- ================ Containers ================ --> |
| 27 | <#macro container> | 29 | <#macro container> |
| 28 | {"type": "container", "id": "${.node["@id"]!""}", "style": "${.node["@style"]!""}", "children": [<#recurse>]} | 30 | <#assign children = []> |
| 31 | <#list .node?children as child> | ||
| 32 | <#assign rendered><#recurse child></#assign> | ||
| 33 | <#if rendered?has_content && !(rendered?starts_with("{\"widgets\""))> | ||
| 34 | <#assign children = children + [rendered]> | ||
| 35 | </#if> | ||
| 36 | </#list> | ||
| 37 | {"type": "container", "children": [${children?join(",")}]} | ||
| 29 | </#macro> | 38 | </#macro> |
| 30 | 39 | ||
| 31 | <#macro label> | 40 | <#macro "container-box"> |
| 32 | {"type": "label", "text": "${ec.resource.expand(.node["@text"], "")?json_string}"} | 41 | {"type": "container-box"<#if .node["box-header"]?has_content>, "header": ${.node["box-header"][0]["@label"]!?json_string}</#if><#if .node["box-body"]?has_content>, "body": ${.node["box-body"][0]["@label"]!?json_string}</#if>} |
| 42 | </#macro> | ||
| 43 | |||
| 44 | <#macro "container-row"><#list .node["row-col"] as rowColNode><#recurse rowColNode></#list></#macro> | ||
| 45 | |||
| 46 | <#macro "container-panel"> | ||
| 47 | {"type": "container-panel"<#if .node["panel-header"]?has_content>, "header": ${.node["panel-header"][0]["@label"]!?json_string}</#if>} | ||
| 48 | </#macro> | ||
| 49 | |||
| 50 | <#macro "container-dialog"> | ||
| 51 | {"type": "container-dialog", "buttonText": ${ec.resource.expand(.node["@button-text"], "")?json_string}} | ||
| 33 | </#macro> | 52 | </#macro> |
| 34 | 53 | ||
| 54 | <#-- ================== Standalone Fields ==================== --> | ||
| 35 | <#macro link> | 55 | <#macro link> |
| 36 | {"type": "link", "text": "${ec.resource.expand(.node["@text"]!"", "")?json_string}", "url": "${.node["@url"]!""}"} | 56 | <#assign linkNode = .node> |
| 57 | <#if linkNode["@condition"]?has_content><#assign conditionResult = ec.getResource().condition(linkNode["@condition"], "")><#else><#assign conditionResult = true></#if> | ||
| 58 | <#if conditionResult> | ||
| 59 | <#assign urlInstance = sri.makeUrlByType(linkNode["@url"]!"", linkNode["@url-type"]!"transition", linkNode, "true")> | ||
| 60 | <#assign linkText = ""> | ||
| 61 | <#if linkNode["@text"]?has_content> | ||
| 62 | <#assign linkText = ec.getResource().expand(linkNode["@text"], "")> | ||
| 63 | <#elseif linkNode["@entity-name"]?has_content> | ||
| 64 | <#assign linkText = sri.getFieldEntityValue(linkNode)!""?string> | ||
| 65 | </#if> | ||
| 66 | <#if !(linkText?has_content) && .node?parent?node_name?ends_with("-field")> | ||
| 67 | <#assign linkText = sri.getFieldValueString(.node?parent?parent)!> | ||
| 68 | </#if> | ||
| 69 | |||
| 70 | <#-- Convert path to dot notation for moqui_render_screen --> | ||
| 71 | <#assign fullPath = urlInstance.sui.fullPathNameList![]> | ||
| 72 | <#assign dotPath = ""> | ||
| 73 | <#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list> | ||
| 74 | |||
| 75 | <#assign paramStr = urlInstance.getParameterString()!""> | ||
| 76 | <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> | ||
| 77 | |||
| 78 | {"type": "link", "text": ${linkText?json_string}, "path": ${dotPath?json_string}} | ||
| 79 | </#if> | ||
| 80 | </#macro> | ||
| 81 | |||
| 82 | <#macro image>{"type": "image", "alt": ${(.node["@alt"]!"")?json_string}, "url": ${(.node["@url"]!"")?json_string}}</#macro> | ||
| 83 | |||
| 84 | <#macro label> | ||
| 85 | <#assign text = ec.resource.expand(.node["@text"], "")> | ||
| 86 | <#assign type = .node["@type"]!"span"> | ||
| 87 | {"type": "label", "text": ${text?json_string}, "labelType": ${type?json_string}} | ||
| 88 | </#macro> | ||
| 89 | |||
| 90 | <#-- ======================= Form ========================= --> | ||
| 91 | <#macro "form-single"> | ||
| 92 | <#assign formNode = sri.getFormNode(.node["@name"])> | ||
| 93 | <#assign mapName = formNode["@map"]!"fieldValues"> | ||
| 94 | |||
| 95 | <#assign fields = []> | ||
| 96 | <#t>${sri.pushSingleFormMapContext(mapName)} | ||
| 97 | <#list formNode["field"] as fieldNode> | ||
| 98 | <#assign fieldSubNode = ""> | ||
| 99 | <#list fieldNode["conditional-field"] as csf><#if ec.resource.condition(csf["@condition"], "")><#assign fieldSubNode = csf><#break></#if></#list> | ||
| 100 | <#if !(fieldSubNode?has_content)><#assign fieldSubNode = fieldNode["default-field"][0]!></#if> | ||
| 101 | <#if fieldSubNode?has_content && !(fieldSubNode["ignored"]?has_content) && !(fieldSubNode["hidden"]?has_content) && !(fieldSubNode["submit"]?has_content) && fieldSubNode?parent["@hide"]! != "true"> | ||
| 102 | <#assign fieldValue = ec.context.get(fieldSubNode?parent["@name"])!""> | ||
| 103 | <#if fieldValue?has_content> | ||
| 104 | <#assign fieldInfo = {"name": (fieldSubNode?parent["@name"]!"")?json_string, "value": (fieldValue!?json_string)}> | ||
| 105 | <#assign fields = fields + [fieldInfo]> | ||
| 106 | </#if> | ||
| 107 | </#if> | ||
| 108 | </#list> | ||
| 109 | <#t>${sri.popContext()} | ||
| 110 | {"type": "form-single", "name": ${formNode["@name"]?json_string}, "map": ${mapName?json_string}, "fields": [${fields?join(",")}]} | ||
| 111 | </#macro> | ||
| 112 | |||
| 113 | <#macro "form-list"> | ||
| 114 | <#assign formInstance = sri.getFormInstance(.node["@name"])> | ||
| 115 | <#assign formListInfo = formInstance.makeFormListRenderInfo()> | ||
| 116 | <#assign formNode = formListInfo.getFormNode()> | ||
| 117 | <#assign formListColumnList = formListInfo.getAllColInfo()> | ||
| 118 | <#assign listObject = formListInfo.getListObject(false)!> | ||
| 119 | |||
| 120 | {"type": "form-list", "name": ${.node["@name"]?json_string}} | ||
| 121 | </#macro> | ||
| 122 | |||
| 123 | <#macro formListSubField fieldNode> | ||
| 124 | <#list fieldNode["conditional-field"] as fieldSubNode> | ||
| 125 | <#if ec.resource.condition(fieldSubNode["@condition"], "")> | ||
| 126 | {"type": "field", "name": ${fieldSubNode["@name"]?json_string}} | ||
| 127 | <#return> | ||
| 128 | </#if> | ||
| 129 | </#list> | ||
| 130 | </#macro> | ||
| 131 | |||
| 132 | <#macro formListWidget fieldSubNode> | ||
| 133 | <#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if> | ||
| 134 | <#if fieldSubNode["submit"]?has_content> | ||
| 135 | <#assign submitText = sri.getFieldValueString(fieldSubNode)!""?json_string> | ||
| 136 | <#assign screenName = sri.getEffectiveScreen().name!""?string> | ||
| 137 | <#assign formNodeObj = sri.getFormNode(.node["@name"])!""> | ||
| 138 | <#assign formName = formNodeObj["@name"]!?string> | ||
| 139 | <#assign fieldName = fieldSubNode["@name"]!""?string> | ||
| 140 | {"type": "submit", "text": ${submitText}, "action": "${screenName}.${formName}.${fieldName}"} | ||
| 141 | </#if> | ||
| 142 | <#recurse fieldSubNode> | ||
| 37 | </#macro> | 143 | </#macro> |
| 144 | |||
| 145 | <#macro fieldTitle fieldSubNode> | ||
| 146 | <#assign titleValue><#if fieldSubNode["@title"]?has_content>${fieldSubNode["@title"]}<#else><#list fieldSubNode?parent["@name"]?split("(?=[A-Z])", "r") as nameWord>${nameWord?cap_first?replace("Id", "ID")}<#if nameWord_has_next> </#if></#list></#if></#assign> | ||
| 147 | ${ec.l10n.localize(titleValue)?json_string} | ||
| 148 | </#macro> | ||
| 149 | |||
| 150 | <#-- ================== Form Field Widgets ==================== --> | ||
| 151 | <#macro "check"> | ||
| 152 | <#assign options = sri.getFieldOptions(.node)!> | ||
| 153 | <#assign currentValue = sri.getFieldValueString(.node)!""> | ||
| 154 | {"type": "check", "value": ${(options.get(currentValue)!currentValue)?json_string}} | ||
| 155 | </#macro> | ||
| 156 | |||
| 157 | <#macro "date-find"></#macro> | ||
| 158 | |||
| 159 | <#macro "date-time"> | ||
| 160 | <#assign javaFormat = .node["@format"]!""> | ||
| 161 | <#if !(javaFormat?has_content)> | ||
| 162 | <#if .node["@type"]! == "time"><#assign javaFormat="HH:mm"> | ||
| 163 | <#elseif .node["@type"]! == "date"><#assign javaFormat="yyyy-MM-dd"> | ||
| 164 | <#else><#assign javaFormat="yyyy-MM-dd HH:mm"></#if> | ||
| 165 | </#if> | ||
| 166 | <#assign fieldValue = sri.getFieldValueString(.node?parent?parent, .node["@default-value"]!"", javaFormat)!""> | ||
| 167 | {"type": "date-time", "name": ${(.node["@name"]!"")?json_string}, "format": ${javaFormat?json_string}, "value": ${fieldValue?json_string!"null"}} | ||
| 168 | </#macro> | ||
| 169 | |||
| 170 | <#macro "display"> | ||
| 171 | <#assign fieldValue = ""> | ||
| 172 | <#assign dispFieldNode = .node?parent?parent> | ||
| 173 | <#if .node["@text"]?has_content> | ||
| 174 | <#assign textMap = {}> | ||
| 175 | <#if .node["@text-map"]?has_content><#assign textMap = ec.getResource().expression(.node["@text-map"], {})!></#if> | ||
| 176 | <#assign fieldValue = ec.getResource().expand(.node["@text"], "", textMap, false)!> | ||
| 177 | <#if .node["@currency-unit-field"]?has_content> | ||
| 178 | <#assign fieldValue = ec.getL10n().formatCurrency(fieldValue, ec.getResource().expression(.node["@currency-unit-field"], ""))!""> | ||
| 179 | </#if> | ||
| 180 | <#else> | ||
| 181 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 182 | </#if> | ||
| 183 | {"type": "display", "value": ${fieldValue?json_string}} | ||
| 184 | </#macro> | ||
| 185 | |||
| 186 | <#macro "display-entity"> | ||
| 187 | <#assign entityValue = sri.getFieldEntityValue(.node)!""> | ||
| 188 | {"type": "display-entity", "value": ${entityValue?json_string}} | ||
| 189 | </#macro> | ||
| 190 | |||
| 191 | <#macro "drop-down"> | ||
| 192 | <#assign options = sri.getFieldOptions(.node)!> | ||
| 193 | <#assign currentValue = sri.getFieldValueString(.node)!""> | ||
| 194 | {"type": "drop-down", "value": ${(options.get(currentValue)!currentValue)?json_string}} | ||
| 195 | </#macro> | ||
| 196 | |||
| 197 | <#macro "text-area"> | ||
| 198 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 199 | {"type": "text-area", "value": ${fieldValue?json_string}} | ||
| 200 | </#macro> | ||
| 201 | |||
| 202 | <#macro "text-line"> | ||
| 203 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 204 | {"type": "text-line", "value": ${fieldValue?json_string}} | ||
| 205 | </#macro> | ||
| 206 | |||
| 207 | <#macro "text-find"> | ||
| 208 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 209 | {"type": "text-find", "value": ${fieldValue?json_string}} | ||
| 210 | </#macro> | ||
| 211 | |||
| 212 | <#macro "submit"> | ||
| 213 | <#assign text = ec.resource.expand(.node["@text"], "")!""> | ||
| 214 | {"type": "submit", "text": ${text?json_string}} | ||
| 215 | </#macro> | ||
| 216 | |||
| 217 | <#macro "password"></#macro> | ||
| 218 | <#macro "hidden"></#macro> | ... | ... |
| ... | @@ -149,11 +149,11 @@ | ... | @@ -149,11 +149,11 @@ |
| 149 | <#macro formListWidget fieldSubNode> | 149 | <#macro formListWidget fieldSubNode> |
| 150 | <#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if> | 150 | <#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if> |
| 151 | <#if fieldSubNode["submit"]?has_content> | 151 | <#if fieldSubNode["submit"]?has_content> |
| 152 | <#assign submitText = sri.getFieldValueString(fieldSubNode)/> | 152 | <#assign submitText = sri.getFieldValueString(fieldSubNode)!> |
| 153 | <#assign screenName = sri.getEffectiveScreen().name/> | 153 | <#assign screenName = sri.getEffectiveScreen().name!?string> |
| 154 | <#assign formName = formNode.getName()/> | 154 | <#assign formName = .node["@name"]!?string> |
| 155 | <#assign fieldName = fieldSubNode["@name"]!> | 155 | <#assign fieldName = fieldSubNode["@name"]!?string> |
| 156 | ${submitText}](#${screenName}.${formName}.${fieldName}) | 156 | [${submitText}](#${screenName}.${formName}.${fieldName}) |
| 157 | </#if> | 157 | </#if> |
| 158 | <#recurse fieldSubNode> | 158 | <#recurse fieldSubNode> |
| 159 | </#macro> | 159 | </#macro> | ... | ... |
| ... | @@ -107,7 +107,7 @@ | ... | @@ -107,7 +107,7 @@ |
| 107 | capabilities: serverCapabilities, | 107 | capabilities: serverCapabilities, |
| 108 | serverInfo: serverInfo, | 108 | serverInfo: serverInfo, |
| 109 | sessionId: sessionId, | 109 | sessionId: sessionId, |
| 110 | instructions: "This server provides access to Moqui ERP through MCP. For common business queries: Use screen_PopCommerce_screen_PopCommerceAdmin_Catalog.Feature_FindFeature to search by features like color or size. Use screen_PopCommerce_screen_PopCommerceAdmin_Catalog.Product_FindProduct for product catalog, screen_PopCommerce_screen_PopCommerceAdmin_Order.FindOrder for order status, screen_PopCommerce_screen_PopCommerceRoot.Customer for customer management, screen_PopCommerce_screen_PopCommerceAdmin_Catalog.Product_EditPrices to check prices and screen_PopCommerce_screen_PopCommerceAdmin.QuickSearch for general searches. All screens support parameterized queries for filtering results." | 110 | instructions: "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." |
| 111 | ] | 111 | ] |
| 112 | 112 | ||
| 113 | ec.logger.info("MCP Initialize for user ${userId} (session ${sessionId}): capabilities negotiated") | 113 | ec.logger.info("MCP Initialize for user ${userId} (session ${sessionId}): capabilities negotiated") |
| ... | @@ -149,6 +149,11 @@ | ... | @@ -149,6 +149,11 @@ |
| 149 | 149 | ||
| 150 | ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}") | 150 | ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}") |
| 151 | 151 | ||
| 152 | // Strip query parameters from path if present | ||
| 153 | if (screenPath.contains("?")) { | ||
| 154 | screenPath = screenPath.split("\\?")[0] | ||
| 155 | } | ||
| 156 | |||
| 152 | // Handle component:// or simple dot notation path | 157 | // Handle component:// or simple dot notation path |
| 153 | def resolvedPath = screenPath | 158 | def resolvedPath = screenPath |
| 154 | def resolvedSubscreen = subscreenName | 159 | def resolvedSubscreen = subscreenName |
| ... | @@ -710,8 +715,8 @@ def startTime = System.currentTimeMillis() | ... | @@ -710,8 +715,8 @@ def startTime = System.currentTimeMillis() |
| 710 | } | 715 | } |
| 711 | } | 716 | } |
| 712 | 717 | ||
| 713 | // Extract MCP-specific data when renderMode is "mcp" | 718 | // Extract MCP-specific data when renderMode is "mcp" or "json" |
| 714 | if (renderMode == "mcp" && finalScreenDef) { | 719 | if ((renderMode == "mcp" || renderMode == "json") && finalScreenDef) { |
| 715 | ec.logger.info("MCP Screen Execution: Extracting MCP data for ${screenPath}") | 720 | ec.logger.info("MCP Screen Execution: Extracting MCP data for ${screenPath}") |
| 716 | 721 | ||
| 717 | // Extract parameters | 722 | // Extract parameters |
| ... | @@ -790,9 +795,9 @@ def startTime = System.currentTimeMillis() | ... | @@ -790,9 +795,9 @@ def startTime = System.currentTimeMillis() |
| 790 | 795 | ||
| 791 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | 796 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 |
| 792 | 797 | ||
| 793 | // Build result based on renderMode | 798 | // Build result based on renderMode |
| 794 | def content = [] | 799 | def content = [] |
| 795 | if (renderMode == "mcp" && mcpData) { | 800 | if ((renderMode == "mcp" || renderMode == "json") && mcpData) { |
| 796 | // Return structured MCP data | 801 | // Return structured MCP data |
| 797 | def mcpResult = [ | 802 | def mcpResult = [ |
| 798 | screenPath: screenPath, | 803 | screenPath: screenPath, |
| ... | @@ -1036,6 +1041,11 @@ def startTime = System.currentTimeMillis() | ... | @@ -1036,6 +1041,11 @@ def startTime = System.currentTimeMillis() |
| 1036 | def currentPath = path ?: "root" | 1041 | def currentPath = path ?: "root" |
| 1037 | def userGroups = ec.user.getUserGroupIdSet().collect { it } | 1042 | def userGroups = ec.user.getUserGroupIdSet().collect { it } |
| 1038 | 1043 | ||
| 1044 | // Strip query parameters from path for screen resolution | ||
| 1045 | if (currentPath.contains("?")) { | ||
| 1046 | currentPath = currentPath.split("\\?")[0] | ||
| 1047 | } | ||
| 1048 | |||
| 1039 | // Helper to convert full component path to simple path (PopCommerce/screen/Root.xml -> PopCommerce.Root) | 1049 | // Helper to convert full component path to simple path (PopCommerce/screen/Root.xml -> PopCommerce.Root) |
| 1040 | def convertToSimplePath = { fullPath -> | 1050 | def convertToSimplePath = { fullPath -> |
| 1041 | if (!fullPath) return null | 1051 | if (!fullPath) return null |
| ... | @@ -1378,6 +1388,11 @@ def startTime = System.currentTimeMillis() | ... | @@ -1378,6 +1388,11 @@ def startTime = System.currentTimeMillis() |
| 1378 | ExecutionContext ec = context.ec | 1388 | ExecutionContext ec = context.ec |
| 1379 | def matches = [] | 1389 | def matches = [] |
| 1380 | 1390 | ||
| 1391 | // Strip query parameters from path if present | ||
| 1392 | if (query.contains("?")) { | ||
| 1393 | query = query.split("\\?")[0] | ||
| 1394 | } | ||
| 1395 | |||
| 1381 | // Helper to convert full component path to simple path | 1396 | // Helper to convert full component path to simple path |
| 1382 | def convertToSimplePath = { fullPath -> | 1397 | def convertToSimplePath = { fullPath -> |
| 1383 | if (!fullPath) return null | 1398 | if (!fullPath) return null |
| ... | @@ -1429,6 +1444,11 @@ def startTime = System.currentTimeMillis() | ... | @@ -1429,6 +1444,11 @@ def startTime = System.currentTimeMillis() |
| 1429 | 1444 | ||
| 1430 | ExecutionContext ec = context.ec | 1445 | ExecutionContext ec = context.ec |
| 1431 | 1446 | ||
| 1447 | // Strip query parameters from path if present | ||
| 1448 | if (path.contains("?")) { | ||
| 1449 | path = path.split("\\?")[0] | ||
| 1450 | } | ||
| 1451 | |||
| 1432 | // Resolve simple path to component path using longest match and traversal | 1452 | // Resolve simple path to component path using longest match and traversal |
| 1433 | def pathParts = path.split('\\.') | 1453 | def pathParts = path.split('\\.') |
| 1434 | def componentName = pathParts[0] | 1454 | def componentName = pathParts[0] | ... | ... |
| ... | @@ -171,39 +171,11 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -171,39 +171,11 @@ class EnhancedMcpServlet extends HttpServlet { |
| 171 | logger.warn("Web facade initialization warning: ${e.message}") | 171 | logger.warn("Web facade initialization warning: ${e.message}") |
| 172 | } | 172 | } |
| 173 | 173 | ||
| 174 | // Handle Basic Authentication directly | 174 | // Authentication is handled by MoquiAuthFilter - user context should already be set |
| 175 | String authzHeader = request.getHeader("Authorization") | 175 | if (!ec.user?.userId) { |
| 176 | boolean authenticated = false | 176 | logger.warn("Enhanced MCP - no authenticated user after MoquiAuthFilter") |
| 177 | |||
| 178 | if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith("Basic ")) { | ||
| 179 | String basicAuthEncoded = authzHeader.substring(6).trim() | ||
| 180 | String basicAuthAsString = new String(basicAuthEncoded.decodeBase64()) | ||
| 181 | int indexOfColon = basicAuthAsString.indexOf(":") | ||
| 182 | if (indexOfColon > 0) { | ||
| 183 | String username = basicAuthAsString.substring(0, indexOfColon) | ||
| 184 | String password = basicAuthAsString.substring(indexOfColon + 1) | ||
| 185 | try { | ||
| 186 | logger.info("LOGGING IN ${username}") | ||
| 187 | authenticated = ec.user.loginUser(username, password) | ||
| 188 | if (authenticated) { | ||
| 189 | logger.info("Enhanced MCP Basic auth successful for user: ${ec.user?.username}") | ||
| 190 | } else { | ||
| 191 | logger.warn("Enhanced MCP Basic auth failed for user: ${username}") | ||
| 192 | } | ||
| 193 | } catch (Exception e) { | ||
| 194 | logger.warn("Enhanced MCP Basic auth exception for user ${username}: ${e.message}") | ||
| 195 | } | ||
| 196 | } else { | ||
| 197 | logger.warn("Enhanced MCP got bad Basic auth credentials string") | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | // Re-enabled proper authentication - UserServices compilation issues resolved | ||
| 202 | if (!authenticated || !ec.user?.userId) { | ||
| 203 | logger.warn("Enhanced MCP authentication failed - authenticated=${authenticated}, userId=${ec.user?.userId}") | ||
| 204 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) | 177 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) |
| 205 | response.setContentType("application/json") | 178 | response.setContentType("application/json") |
| 206 | response.setHeader("WWW-Authenticate", "Basic realm=\"Moqui MCP\"") | ||
| 207 | response.writer.write(JsonOutput.toJson([ | 179 | response.writer.write(JsonOutput.toJson([ |
| 208 | jsonrpc: "2.0", | 180 | jsonrpc: "2.0", |
| 209 | error: [code: -32003, message: "Authentication required. Use Basic auth with valid Moqui credentials."], | 181 | error: [code: -32003, message: "Authentication required. Use Basic auth with valid Moqui credentials."], |
| ... | @@ -560,8 +532,8 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -560,8 +532,8 @@ class EnhancedMcpServlet extends HttpServlet { |
| 560 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}") | 532 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}") |
| 561 | 533 | ||
| 562 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 | 534 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 |
| 563 | // Client MUST include Accept header listing both application/json and text/event-stream | 535 | // Client MUST include Accept header with either application/json or text/event-stream |
| 564 | if (!acceptHeader || !(acceptHeader.contains("application/json") && acceptHeader.contains("text/event-stream"))) { | 536 | if (!acceptHeader || !(acceptHeader.contains("application/json") || acceptHeader.contains("text/event-stream"))) { |
| 565 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | 537 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) |
| 566 | response.setContentType("application/json") | 538 | response.setContentType("application/json") |
| 567 | response.writer.write(JsonOutput.toJson([ | 539 | response.writer.write(JsonOutput.toJson([ | ... | ... |
| 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 | */ | ||
| 14 | package org.moqui.mcp | ||
| 15 | |||
| 16 | import org.slf4j.Logger | ||
| 17 | import org.slf4j.LoggerFactory | ||
| 18 | |||
| 19 | import javax.servlet.* | ||
| 20 | import javax.servlet.http.HttpServletRequest | ||
| 21 | import javax.servlet.http.HttpServletResponse | ||
| 22 | |||
| 23 | class McpFilter implements Filter { | ||
| 24 | protected final static Logger logger = LoggerFactory.getLogger(McpFilter.class) | ||
| 25 | |||
| 26 | private EnhancedMcpServlet mcpServlet = new EnhancedMcpServlet() | ||
| 27 | |||
| 28 | @Override | ||
| 29 | void init(FilterConfig filterConfig) throws ServletException { | ||
| 30 | logger.info("========== MCP FILTER INITIALIZED ==========") | ||
| 31 | // Initialize the servlet with filter config | ||
| 32 | mcpServlet.init(new ServletConfig() { | ||
| 33 | @Override | ||
| 34 | String getServletName() { return "McpFilter" } | ||
| 35 | @Override | ||
| 36 | ServletContext getServletContext() { return filterConfig.getServletContext() } | ||
| 37 | @Override | ||
| 38 | String getInitParameter(String name) { return filterConfig.getInitParameter(name) } | ||
| 39 | @Override | ||
| 40 | Enumeration<String> getInitParameterNames() { return filterConfig.getInitParameterNames() } | ||
| 41 | }) | ||
| 42 | } | ||
| 43 | |||
| 44 | @Override | ||
| 45 | void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) | ||
| 46 | throws IOException, ServletException { | ||
| 47 | |||
| 48 | logger.info("========== MCP FILTER DOFILTER CALLED ==========") | ||
| 49 | |||
| 50 | if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) { | ||
| 51 | HttpServletRequest httpRequest = (HttpServletRequest) request | ||
| 52 | HttpServletResponse httpResponse = (HttpServletResponse) response | ||
| 53 | |||
| 54 | // Check if this is an MCP request | ||
| 55 | String path = httpRequest.getRequestURI() | ||
| 56 | logger.info("========== MCP FILTER PATH: {} ==========", path) | ||
| 57 | |||
| 58 | if (path != null && path.contains("/mcpservlet")) { | ||
| 59 | logger.info("========== MCP FILTER HANDLING REQUEST ==========") | ||
| 60 | try { | ||
| 61 | // Handle MCP request directly, don't continue chain | ||
| 62 | mcpServlet.service(httpRequest, httpResponse) | ||
| 63 | return | ||
| 64 | } catch (Exception e) { | ||
| 65 | logger.error("Error in MCP filter", e) | ||
| 66 | // Send error response directly | ||
| 67 | httpResponse.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) | ||
| 68 | httpResponse.setContentType("application/json") | ||
| 69 | httpResponse.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 70 | jsonrpc: "2.0", | ||
| 71 | error: [code: -32603, message: "Internal error: " + e.message], | ||
| 72 | id: null | ||
| 73 | ])) | ||
| 74 | return | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | // Not an MCP request, continue chain | ||
| 80 | chain.doFilter(request, response) | ||
| 81 | } | ||
| 82 | |||
| 83 | @Override | ||
| 84 | void destroy() { | ||
| 85 | mcpServlet.destroy() | ||
| 86 | logger.info("McpFilter destroyed") | ||
| 87 | } | ||
| 88 | } | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 1 | /* | ||
| 2 | * Moqui MCP Widget Description System | ||
| 3 | * | ||
| 4 | * Describes Moqui UI elements in a structured format for LLMs to understand and interact with. | ||
| 5 | * Instead of rendering visual markup (markdown tables), we describe each widget semantically. | ||
| 6 | */ | ||
| 7 | |||
| 8 | // Widget type constants | ||
| 9 | class WidgetType { | ||
| 10 | static final String FORM = 'form' | ||
| 11 | static final String FORM_SINGLE = 'form-single' | ||
| 12 | static final String FORM_LIST = 'form-list' | ||
| 13 | static final String FORM_FIELD = 'field' | ||
| 14 | static final String BUTTON = 'button' | ||
| 15 | static final String LINK = 'link' | ||
| 16 | static final String DISPLAY = 'display' | ||
| 17 | static final String CHECK = 'check' | ||
| 18 | static final String TEXT_LINE = 'text-line' | ||
| 19 | static final String DATE_TIME = 'date-time' | ||
| 20 | static final String LABEL = 'label' | ||
| 21 | static final String SECTION = 'section' | ||
| 22 | static final String CONTAINER = 'container' | ||
| 23 | static final String SUBSCREENS_MENU = 'subscreens-menu' | ||
| 24 | } | ||
| 25 | |||
| 26 | // Data types | ||
| 27 | class DataType { | ||
| 28 | static final String STRING = 'string' | ||
| 29 | static final String NUMBER = 'number' | ||
| 30 | static final String CURRENCY = 'currency' | ||
| 31 | static final String DATE = 'date' | ||
| 32 | static final String DATE_TIME = 'datetime' | ||
| 33 | static final String BOOLEAN = 'boolean' | ||
| 34 | static final String ENUM = 'enum' | ||
| 35 | } | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Widget description for LLM consumption | ||
| 39 | */ | ||
| 40 | class WidgetDescription { | ||
| 41 | static Map description(formName, widgets) { | ||
| 42 | return [ | ||
| 43 | type: WidgetType.FORM, | ||
| 44 | name: formName, | ||
| 45 | widgets: widgets | ||
| 46 | ] | ||
| 47 | } | ||
| 48 | |||
| 49 | static Map formField(name, type, value, Map options = [:]) { | ||
| 50 | return [ | ||
| 51 | type: WidgetType.FORM_FIELD, | ||
| 52 | name: name, | ||
| 53 | dataType: type, | ||
| 54 | value: value | ||
| 55 | ] + options | ||
| 56 | } | ||
| 57 | |||
| 58 | static Map button(text, action, Map parameters = [:]) { | ||
| 59 | return [ | ||
| 60 | type: WidgetType.BUTTON, | ||
| 61 | text: text, | ||
| 62 | action: action, | ||
| 63 | parameters: parameters | ||
| 64 | ] | ||
| 65 | } | ||
| 66 | |||
| 67 | static Map link(text, action, Map parameters = [:]) { | ||
| 68 | return [ | ||
| 69 | type: WidgetType.LINK, | ||
| 70 | text: text, | ||
| 71 | action: action, | ||
| 72 | parameters: parameters | ||
| 73 | ] | ||
| 74 | } | ||
| 75 | |||
| 76 | static Map display(text, value) { | ||
| 77 | return [ | ||
| 78 | type: WidgetType.DISPLAY, | ||
| 79 | text: text, | ||
| 80 | value: value | ||
| 81 | ] | ||
| 82 | } | ||
| 83 | |||
| 84 | static Map label(text) { | ||
| 85 | return [ | ||
| 86 | type: WidgetType.LABEL, | ||
| 87 | text: text | ||
| 88 | ] | ||
| 89 | } | ||
| 90 | |||
| 91 | static Map formList(formName, columns, rows, List actions = []) { | ||
| 92 | return [ | ||
| 93 | type: WidgetType.FORM_LIST, | ||
| 94 | name: formName, | ||
| 95 | columns: columns, | ||
| 96 | rows: rows, | ||
| 97 | actions: actions | ||
| 98 | ] | ||
| 99 | } | ||
| 100 | |||
| 101 | static Map column(name, header, fieldType, Map options = [:]) { | ||
| 102 | return [ | ||
| 103 | name: name, | ||
| 104 | header: header, | ||
| 105 | fieldType: fieldType | ||
| 106 | ] + options | ||
| 107 | } | ||
| 108 | |||
| 109 | static Map row(values, List actions = []) { | ||
| 110 | return [ | ||
| 111 | values: values, | ||
| 112 | actions: actions | ||
| 113 | ] | ||
| 114 | } | ||
| 115 | } |
-
Please register or sign in to post a comment