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 @@
<webapp-list>
<webapp name="webroot" http-port="8080">
<filter name="McpAuthFilter" class="org.moqui.impl.webapp.MoquiAuthFilter" async-supported="true">
<url-pattern>/mcp/*</url-pattern>
</filter>
<servlet name="EnhancedMcpServlet" class="org.moqui.mcp.EnhancedMcpServlet"
load-on-startup="5" async-supported="true">
<init-param name="keepAliveIntervalSeconds" value="30"/>
......
......@@ -24,6 +24,9 @@
<!-- Webapp Configuration -->
<webapp-list>
<webapp name="webroot">
<filter name="McpAuthFilter" class="org.moqui.impl.webapp.MoquiAuthFilter" async-supported="true">
<url-pattern>/mcp/*</url-pattern>
</filter>
<servlet name="EnhancedMcpServlet" class="org.moqui.mcp.EnhancedMcpServlet"
load-on-startup="5" async-supported="true">
<init-param name="keepAliveIntervalSeconds" value="30"/>
......
<#--
Moqui JSON Optimized Macros
Renders screens in structured JSON format.
-->
Moqui MCP JSON Macros
Renders screens in structured JSON format for LLM consumption.
-->
<#include "DefaultScreenMacros.any.ftl"/>
......@@ -21,17 +21,198 @@
<#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro>
<#-- ================ Section ================ -->
<#macro section>{"type": "section", "name": "${.node["@name"]}", "content": ${sri.renderSection(.node["@name"])}}</#macro>
<#macro section>{"type": "section", "name": ${(.node["@name"]!"")?json_string}, "content": ${sri.renderSection(.node["@name"])}}</#macro>
<#macro "section-iterate">${sri.renderSection(.node["@name"])}</#macro>
<#macro "section-include">${sri.renderSectionInclude(.node)}</#macro>
<#-- ================ Containers ================ -->
<#macro container>
{"type": "container", "id": "${.node["@id"]!""}", "style": "${.node["@style"]!""}", "children": [<#recurse>]}
<#assign children = []>
<#list .node?children as child>
<#assign rendered><#recurse child></#assign>
<#if rendered?has_content && !(rendered?starts_with("{\"widgets\""))>
<#assign children = children + [rendered]>
</#if>
</#list>
{"type": "container", "children": [${children?join(",")}]}
</#macro>
<#macro label>
{"type": "label", "text": "${ec.resource.expand(.node["@text"], "")?json_string}"}
<#macro "container-box">
{"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>}
</#macro>
<#macro "container-row"><#list .node["row-col"] as rowColNode><#recurse rowColNode></#list></#macro>
<#macro "container-panel">
{"type": "container-panel"<#if .node["panel-header"]?has_content>, "header": ${.node["panel-header"][0]["@label"]!?json_string}</#if>}
</#macro>
<#macro "container-dialog">
{"type": "container-dialog", "buttonText": ${ec.resource.expand(.node["@button-text"], "")?json_string}}
</#macro>
<#-- ================== Standalone Fields ==================== -->
<#macro link>
{"type": "link", "text": "${ec.resource.expand(.node["@text"]!"", "")?json_string}", "url": "${.node["@url"]!""}"}
<#assign linkNode = .node>
<#if linkNode["@condition"]?has_content><#assign conditionResult = ec.getResource().condition(linkNode["@condition"], "")><#else><#assign conditionResult = true></#if>
<#if conditionResult>
<#assign urlInstance = sri.makeUrlByType(linkNode["@url"]!"", linkNode["@url-type"]!"transition", linkNode, "true")>
<#assign linkText = "">
<#if linkNode["@text"]?has_content>
<#assign linkText = ec.getResource().expand(linkNode["@text"], "")>
<#elseif linkNode["@entity-name"]?has_content>
<#assign linkText = sri.getFieldEntityValue(linkNode)!""?string>
</#if>
<#if !(linkText?has_content) && .node?parent?node_name?ends_with("-field")>
<#assign linkText = sri.getFieldValueString(.node?parent?parent)!>
</#if>
<#-- Convert path to dot notation for moqui_render_screen -->
<#assign fullPath = urlInstance.sui.fullPathNameList![]>
<#assign dotPath = "">
<#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list>
<#assign paramStr = urlInstance.getParameterString()!"">
<#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if>
{"type": "link", "text": ${linkText?json_string}, "path": ${dotPath?json_string}}
</#if>
</#macro>
<#macro image>{"type": "image", "alt": ${(.node["@alt"]!"")?json_string}, "url": ${(.node["@url"]!"")?json_string}}</#macro>
<#macro label>
<#assign text = ec.resource.expand(.node["@text"], "")>
<#assign type = .node["@type"]!"span">
{"type": "label", "text": ${text?json_string}, "labelType": ${type?json_string}}
</#macro>
<#-- ======================= Form ========================= -->
<#macro "form-single">
<#assign formNode = sri.getFormNode(.node["@name"])>
<#assign mapName = formNode["@map"]!"fieldValues">
<#assign fields = []>
<#t>${sri.pushSingleFormMapContext(mapName)}
<#list formNode["field"] as fieldNode>
<#assign fieldSubNode = "">
<#list fieldNode["conditional-field"] as csf><#if ec.resource.condition(csf["@condition"], "")><#assign fieldSubNode = csf><#break></#if></#list>
<#if !(fieldSubNode?has_content)><#assign fieldSubNode = fieldNode["default-field"][0]!></#if>
<#if fieldSubNode?has_content && !(fieldSubNode["ignored"]?has_content) && !(fieldSubNode["hidden"]?has_content) && !(fieldSubNode["submit"]?has_content) && fieldSubNode?parent["@hide"]! != "true">
<#assign fieldValue = ec.context.get(fieldSubNode?parent["@name"])!"">
<#if fieldValue?has_content>
<#assign fieldInfo = {"name": (fieldSubNode?parent["@name"]!"")?json_string, "value": (fieldValue!?json_string)}>
<#assign fields = fields + [fieldInfo]>
</#if>
</#if>
</#list>
<#t>${sri.popContext()}
{"type": "form-single", "name": ${formNode["@name"]?json_string}, "map": ${mapName?json_string}, "fields": [${fields?join(",")}]}
</#macro>
<#macro "form-list">
<#assign formInstance = sri.getFormInstance(.node["@name"])>
<#assign formListInfo = formInstance.makeFormListRenderInfo()>
<#assign formNode = formListInfo.getFormNode()>
<#assign formListColumnList = formListInfo.getAllColInfo()>
<#assign listObject = formListInfo.getListObject(false)!>
{"type": "form-list", "name": ${.node["@name"]?json_string}}
</#macro>
<#macro formListSubField fieldNode>
<#list fieldNode["conditional-field"] as fieldSubNode>
<#if ec.resource.condition(fieldSubNode["@condition"], "")>
{"type": "field", "name": ${fieldSubNode["@name"]?json_string}}
<#return>
</#if>
</#list>
</#macro>
<#macro formListWidget fieldSubNode>
<#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if>
<#if fieldSubNode["submit"]?has_content>
<#assign submitText = sri.getFieldValueString(fieldSubNode)!""?json_string>
<#assign screenName = sri.getEffectiveScreen().name!""?string>
<#assign formNodeObj = sri.getFormNode(.node["@name"])!"">
<#assign formName = formNodeObj["@name"]!?string>
<#assign fieldName = fieldSubNode["@name"]!""?string>
{"type": "submit", "text": ${submitText}, "action": "${screenName}.${formName}.${fieldName}"}
</#if>
<#recurse fieldSubNode>
</#macro>
<#macro fieldTitle fieldSubNode>
<#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>
${ec.l10n.localize(titleValue)?json_string}
</#macro>
<#-- ================== Form Field Widgets ==================== -->
<#macro "check">
<#assign options = sri.getFieldOptions(.node)!>
<#assign currentValue = sri.getFieldValueString(.node)!"">
{"type": "check", "value": ${(options.get(currentValue)!currentValue)?json_string}}
</#macro>
<#macro "date-find"></#macro>
<#macro "date-time">
<#assign javaFormat = .node["@format"]!"">
<#if !(javaFormat?has_content)>
<#if .node["@type"]! == "time"><#assign javaFormat="HH:mm">
<#elseif .node["@type"]! == "date"><#assign javaFormat="yyyy-MM-dd">
<#else><#assign javaFormat="yyyy-MM-dd HH:mm"></#if>
</#if>
<#assign fieldValue = sri.getFieldValueString(.node?parent?parent, .node["@default-value"]!"", javaFormat)!"">
{"type": "date-time", "name": ${(.node["@name"]!"")?json_string}, "format": ${javaFormat?json_string}, "value": ${fieldValue?json_string!"null"}}
</#macro>
<#macro "display">
<#assign fieldValue = "">
<#assign dispFieldNode = .node?parent?parent>
<#if .node["@text"]?has_content>
<#assign textMap = {}>
<#if .node["@text-map"]?has_content><#assign textMap = ec.getResource().expression(.node["@text-map"], {})!></#if>
<#assign fieldValue = ec.getResource().expand(.node["@text"], "", textMap, false)!>
<#if .node["@currency-unit-field"]?has_content>
<#assign fieldValue = ec.getL10n().formatCurrency(fieldValue, ec.getResource().expression(.node["@currency-unit-field"], ""))!"">
</#if>
<#else>
<#assign fieldValue = sri.getFieldValueString(.node)!"">
</#if>
{"type": "display", "value": ${fieldValue?json_string}}
</#macro>
<#macro "display-entity">
<#assign entityValue = sri.getFieldEntityValue(.node)!"">
{"type": "display-entity", "value": ${entityValue?json_string}}
</#macro>
<#macro "drop-down">
<#assign options = sri.getFieldOptions(.node)!>
<#assign currentValue = sri.getFieldValueString(.node)!"">
{"type": "drop-down", "value": ${(options.get(currentValue)!currentValue)?json_string}}
</#macro>
<#macro "text-area">
<#assign fieldValue = sri.getFieldValueString(.node)!"">
{"type": "text-area", "value": ${fieldValue?json_string}}
</#macro>
<#macro "text-line">
<#assign fieldValue = sri.getFieldValueString(.node)!"">
{"type": "text-line", "value": ${fieldValue?json_string}}
</#macro>
<#macro "text-find">
<#assign fieldValue = sri.getFieldValueString(.node)!"">
{"type": "text-find", "value": ${fieldValue?json_string}}
</#macro>
<#macro "submit">
<#assign text = ec.resource.expand(.node["@text"], "")!"">
{"type": "submit", "text": ${text?json_string}}
</#macro>
<#macro "password"></#macro>
<#macro "hidden"></#macro>
......
......@@ -149,11 +149,11 @@
<#macro formListWidget fieldSubNode>
<#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if>
<#if fieldSubNode["submit"]?has_content>
<#assign submitText = sri.getFieldValueString(fieldSubNode)/>
<#assign screenName = sri.getEffectiveScreen().name/>
<#assign formName = formNode.getName()/>
<#assign fieldName = fieldSubNode["@name"]!>
${submitText}](#${screenName}.${formName}.${fieldName})
<#assign submitText = sri.getFieldValueString(fieldSubNode)!>
<#assign screenName = sri.getEffectiveScreen().name!?string>
<#assign formName = .node["@name"]!?string>
<#assign fieldName = fieldSubNode["@name"]!?string>
[${submitText}](#${screenName}.${formName}.${fieldName})
</#if>
<#recurse fieldSubNode>
</#macro>
......
......@@ -107,7 +107,7 @@
capabilities: serverCapabilities,
serverInfo: serverInfo,
sessionId: sessionId,
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."
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."
]
ec.logger.info("MCP Initialize for user ${userId} (session ${sessionId}): capabilities negotiated")
......@@ -149,6 +149,11 @@
ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}")
// Strip query parameters from path if present
if (screenPath.contains("?")) {
screenPath = screenPath.split("\\?")[0]
}
// Handle component:// or simple dot notation path
def resolvedPath = screenPath
def resolvedSubscreen = subscreenName
......@@ -710,8 +715,8 @@ def startTime = System.currentTimeMillis()
}
}
// Extract MCP-specific data when renderMode is "mcp"
if (renderMode == "mcp" && finalScreenDef) {
// Extract MCP-specific data when renderMode is "mcp" or "json"
if ((renderMode == "mcp" || renderMode == "json") && finalScreenDef) {
ec.logger.info("MCP Screen Execution: Extracting MCP data for ${screenPath}")
// Extract parameters
......@@ -790,9 +795,9 @@ def startTime = System.currentTimeMillis()
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Build result based on renderMode
def content = []
if (renderMode == "mcp" && mcpData) {
// Build result based on renderMode
def content = []
if ((renderMode == "mcp" || renderMode == "json") && mcpData) {
// Return structured MCP data
def mcpResult = [
screenPath: screenPath,
......@@ -1036,6 +1041,11 @@ def startTime = System.currentTimeMillis()
def currentPath = path ?: "root"
def userGroups = ec.user.getUserGroupIdSet().collect { it }
// Strip query parameters from path for screen resolution
if (currentPath.contains("?")) {
currentPath = currentPath.split("\\?")[0]
}
// Helper to convert full component path to simple path (PopCommerce/screen/Root.xml -> PopCommerce.Root)
def convertToSimplePath = { fullPath ->
if (!fullPath) return null
......@@ -1378,6 +1388,11 @@ def startTime = System.currentTimeMillis()
ExecutionContext ec = context.ec
def matches = []
// Strip query parameters from path if present
if (query.contains("?")) {
query = query.split("\\?")[0]
}
// Helper to convert full component path to simple path
def convertToSimplePath = { fullPath ->
if (!fullPath) return null
......@@ -1429,6 +1444,11 @@ def startTime = System.currentTimeMillis()
ExecutionContext ec = context.ec
// Strip query parameters from path if present
if (path.contains("?")) {
path = path.split("\\?")[0]
}
// Resolve simple path to component path using longest match and traversal
def pathParts = path.split('\\.')
def componentName = pathParts[0]
......
......@@ -171,39 +171,11 @@ class EnhancedMcpServlet extends HttpServlet {
logger.warn("Web facade initialization warning: ${e.message}")
}
// Handle Basic Authentication directly
String authzHeader = request.getHeader("Authorization")
boolean authenticated = false
if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith("Basic ")) {
String basicAuthEncoded = authzHeader.substring(6).trim()
String basicAuthAsString = new String(basicAuthEncoded.decodeBase64())
int indexOfColon = basicAuthAsString.indexOf(":")
if (indexOfColon > 0) {
String username = basicAuthAsString.substring(0, indexOfColon)
String password = basicAuthAsString.substring(indexOfColon + 1)
try {
logger.info("LOGGING IN ${username}")
authenticated = ec.user.loginUser(username, password)
if (authenticated) {
logger.info("Enhanced MCP Basic auth successful for user: ${ec.user?.username}")
} else {
logger.warn("Enhanced MCP Basic auth failed for user: ${username}")
}
} catch (Exception e) {
logger.warn("Enhanced MCP Basic auth exception for user ${username}: ${e.message}")
}
} else {
logger.warn("Enhanced MCP got bad Basic auth credentials string")
}
}
// Re-enabled proper authentication - UserServices compilation issues resolved
if (!authenticated || !ec.user?.userId) {
logger.warn("Enhanced MCP authentication failed - authenticated=${authenticated}, userId=${ec.user?.userId}")
// Authentication is handled by MoquiAuthFilter - user context should already be set
if (!ec.user?.userId) {
logger.warn("Enhanced MCP - no authenticated user after MoquiAuthFilter")
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)
response.setContentType("application/json")
response.setHeader("WWW-Authenticate", "Basic realm=\"Moqui MCP\"")
response.writer.write(JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32003, message: "Authentication required. Use Basic auth with valid Moqui credentials."],
......@@ -560,8 +532,8 @@ class EnhancedMcpServlet extends HttpServlet {
logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}")
// Validate Accept header per MCP 2025-11-25 spec requirement #2
// Client MUST include Accept header listing both application/json and text/event-stream
if (!acceptHeader || !(acceptHeader.contains("application/json") && acceptHeader.contains("text/event-stream"))) {
// Client MUST include Accept header with either application/json or text/event-stream
if (!acceptHeader || !(acceptHeader.contains("application/json") || acceptHeader.contains("text/event-stream"))) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(JsonOutput.toJson([
......
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package org.moqui.mcp
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import javax.servlet.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class McpFilter implements Filter {
protected final static Logger logger = LoggerFactory.getLogger(McpFilter.class)
private EnhancedMcpServlet mcpServlet = new EnhancedMcpServlet()
@Override
void init(FilterConfig filterConfig) throws ServletException {
logger.info("========== MCP FILTER INITIALIZED ==========")
// Initialize the servlet with filter config
mcpServlet.init(new ServletConfig() {
@Override
String getServletName() { return "McpFilter" }
@Override
ServletContext getServletContext() { return filterConfig.getServletContext() }
@Override
String getInitParameter(String name) { return filterConfig.getInitParameter(name) }
@Override
Enumeration<String> getInitParameterNames() { return filterConfig.getInitParameterNames() }
})
}
@Override
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
logger.info("========== MCP FILTER DOFILTER CALLED ==========")
if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
HttpServletRequest httpRequest = (HttpServletRequest) request
HttpServletResponse httpResponse = (HttpServletResponse) response
// Check if this is an MCP request
String path = httpRequest.getRequestURI()
logger.info("========== MCP FILTER PATH: {} ==========", path)
if (path != null && path.contains("/mcpservlet")) {
logger.info("========== MCP FILTER HANDLING REQUEST ==========")
try {
// Handle MCP request directly, don't continue chain
mcpServlet.service(httpRequest, httpResponse)
return
} catch (Exception e) {
logger.error("Error in MCP filter", e)
// Send error response directly
httpResponse.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
httpResponse.setContentType("application/json")
httpResponse.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32603, message: "Internal error: " + e.message],
id: null
]))
return
}
}
}
// Not an MCP request, continue chain
chain.doFilter(request, response)
}
@Override
void destroy() {
mcpServlet.destroy()
logger.info("McpFilter destroyed")
}
}
\ No newline at end of file
/*
* Moqui MCP Widget Description System
*
* Describes Moqui UI elements in a structured format for LLMs to understand and interact with.
* Instead of rendering visual markup (markdown tables), we describe each widget semantically.
*/
// Widget type constants
class WidgetType {
static final String FORM = 'form'
static final String FORM_SINGLE = 'form-single'
static final String FORM_LIST = 'form-list'
static final String FORM_FIELD = 'field'
static final String BUTTON = 'button'
static final String LINK = 'link'
static final String DISPLAY = 'display'
static final String CHECK = 'check'
static final String TEXT_LINE = 'text-line'
static final String DATE_TIME = 'date-time'
static final String LABEL = 'label'
static final String SECTION = 'section'
static final String CONTAINER = 'container'
static final String SUBSCREENS_MENU = 'subscreens-menu'
}
// Data types
class DataType {
static final String STRING = 'string'
static final String NUMBER = 'number'
static final String CURRENCY = 'currency'
static final String DATE = 'date'
static final String DATE_TIME = 'datetime'
static final String BOOLEAN = 'boolean'
static final String ENUM = 'enum'
}
/**
* Widget description for LLM consumption
*/
class WidgetDescription {
static Map description(formName, widgets) {
return [
type: WidgetType.FORM,
name: formName,
widgets: widgets
]
}
static Map formField(name, type, value, Map options = [:]) {
return [
type: WidgetType.FORM_FIELD,
name: name,
dataType: type,
value: value
] + options
}
static Map button(text, action, Map parameters = [:]) {
return [
type: WidgetType.BUTTON,
text: text,
action: action,
parameters: parameters
]
}
static Map link(text, action, Map parameters = [:]) {
return [
type: WidgetType.LINK,
text: text,
action: action,
parameters: parameters
]
}
static Map display(text, value) {
return [
type: WidgetType.DISPLAY,
text: text,
value: value
]
}
static Map label(text) {
return [
type: WidgetType.LABEL,
text: text
]
}
static Map formList(formName, columns, rows, List actions = []) {
return [
type: WidgetType.FORM_LIST,
name: formName,
columns: columns,
rows: rows,
actions: actions
]
}
static Map column(name, header, fieldType, Map options = [:]) {
return [
name: name,
header: header,
fieldType: fieldType
] + options
}
static Map row(values, List actions = []) {
return [
values: values,
actions: actions
]
}
}