8489faad by Ean Schuessler

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
1 parent 9f56d892
...@@ -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 }