Update MCP services, servlet, and screen macros for enhanced markdown rendering and spec compliance
Showing
8 changed files
with
367 additions
and
82 deletions
| ... | @@ -24,4 +24,13 @@ | ... | @@ -24,4 +24,13 @@ |
| 24 | </webapp> | 24 | </webapp> |
| 25 | </webapp-list> | 25 | </webapp-list> |
| 26 | 26 | ||
| 27 | <screen-facade> | ||
| 28 | <screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true" | ||
| 29 | macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/> | ||
| 30 | <screen-text-output type="json" mime-type="application/json" always-standalone="true" | ||
| 31 | macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.json.ftl"/> | ||
| 32 | <widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> | ||
| 33 | <widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> | ||
| 34 | </screen-facade> | ||
| 35 | |||
| 27 | </moqui-conf> | 36 | </moqui-conf> |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
screen/macro/DefaultScreenMacros.any.ftl
0 → 100644
| 1 | <#-- | ||
| 2 | This software is in the public domain under CC0 1.0 Universal plus a Grant of Patent License. | ||
| 3 | |||
| 4 | To the extent possible under law, the author(s) have dedicated all | ||
| 5 | copyright and related and neighboring rights to this software to the | ||
| 6 | public domain worldwide. This software is distributed without any | ||
| 7 | warranty. | ||
| 8 | |||
| 9 | You should have received a copy of the CC0 Public Domain Dedication | ||
| 10 | along with this software (see the LICENSE.md file). If not, see | ||
| 11 | <http://creativecommons.org/publicdomain/zero/1.0/>. | ||
| 12 | --> | ||
| 13 | <#-- NOTE: no empty lines before the first #macro otherwise FTL outputs empty lines --> | ||
| 14 | <#-- ==================== Includes ==================== --> | ||
| 15 | <#macro "include-screen">${sri.renderIncludeScreen(.node["@location"], .node["@share-scope"]!)}</#macro> | ||
| 16 | |||
| 17 | <#-- ============== Render Mode Elements ============== --> | ||
| 18 | <#macro "render-mode"> | ||
| 19 | <#if .node["text"]?has_content> | ||
| 20 | <#list .node["text"] as textNode><#if !textNode["@type"]?has_content || textNode["@type"] == "any"><#local textToUse = textNode/></#if></#list> | ||
| 21 | <#list .node["text"] as textNode><#if textNode["@type"]?has_content && textNode["@type"]?split(",")?seq_contains(sri.getRenderMode())><#local textToUse = textNode></#if></#list> | ||
| 22 | <#if textToUse??><@renderText textNode=textToUse/></#if> | ||
| 23 | </#if> | ||
| 24 | </#macro> | ||
| 25 | <#macro text> | ||
| 26 | <#if !.node["@type"]?has_content || (.node["@type"]?split(",")?seq_contains(sri.getRenderMode()))><@renderText textNode=.node/></#if> | ||
| 27 | </#macro> | ||
| 28 | <#macro renderText textNode> | ||
| 29 | <#if textNode["@location"]?has_content> | ||
| 30 | <#assign textLocation = ec.getResource().expandNoL10n(textNode["@location"], "")> | ||
| 31 | <#if sri.doBoundaryComments() && textNode["@no-boundary-comment"]! != "true"> | ||
| 32 | <!-- BEGIN render-mode.text[@location=${textLocation}][@template=${textNode["@template"]!"true"}] --> | ||
| 33 | </#if> | ||
| 34 | <#-- NOTE: this still won't encode templates that are rendered to the writer --> | ||
| 35 | <#t><#if .node["@encode"]! == "true">${sri.renderText(textLocation, textNode["@template"]!)?html}<#else>${sri.renderText(textLocation, textNode["@template"]!)}</#if> | ||
| 36 | <#if sri.doBoundaryComments() && textNode["@no-boundary-comment"]! != "true"><!-- END render-mode.text[@location=${textLocation}][@template=${textNode["@template"]!"true"}] --></#if> | ||
| 37 | </#if> | ||
| 38 | <#assign inlineTemplateSource = textNode.@@text!> | ||
| 39 | <#if inlineTemplateSource?has_content> | ||
| 40 | <#if sri.doBoundaryComments() && textNode["@no-boundary-comment"]! != "true"><!-- BEGIN render-mode.text[inline][@template=${textNode["@template"]!"true"}] --></#if> | ||
| 41 | <#if !textNode["@template"]?has_content || textNode["@template"] == "true"> | ||
| 42 | <#assign inlineTemplate = [inlineTemplateSource, sri.getActiveScreenDef().location + ".render_mode.text"]?interpret> | ||
| 43 | <@inlineTemplate/> | ||
| 44 | <#else> | ||
| 45 | <#if .node["@encode"]! == "true">${inlineTemplateSource?html}<#else>${inlineTemplateSource}</#if> | ||
| 46 | </#if> | ||
| 47 | <#if sri.doBoundaryComments() && textNode["@no-boundary-comment"]! != "true"><!-- END render-mode.text[inline][@template=${textNode["@template"]!"true"}] --></#if> | ||
| 48 | </#if> | ||
| 49 | </#macro> | ||
| 50 |
screen/macro/DefaultScreenMacros.json.ftl
0 → 100644
| 1 | <#-- | ||
| 2 | Moqui JSON Optimized Macros | ||
| 3 | Renders screens in structured JSON format. | ||
| 4 | --> | ||
| 5 | |||
| 6 | <#include "DefaultScreenMacros.any.ftl"/> | ||
| 7 | |||
| 8 | <#macro @element></#macro> | ||
| 9 | |||
| 10 | <#macro screen>{"screen": {<#recurse>}}</#macro> | ||
| 11 | |||
| 12 | <#macro widgets> | ||
| 13 | "widgets": [<#recurse>] | ||
| 14 | </#macro> | ||
| 15 | |||
| 16 | <#macro "fail-widgets"><#recurse></#macro> | ||
| 17 | |||
| 18 | <#-- ================ Subscreens ================ --> | ||
| 19 | <#macro "subscreens-menu"></#macro> | ||
| 20 | <#macro "subscreens-active">{"type": "subscreens-active", "content": ${sri.renderSubscreen()}}</#macro> | ||
| 21 | <#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro> | ||
| 22 | |||
| 23 | <#-- ================ Section ================ --> | ||
| 24 | <#macro section>{"type": "section", "name": "${.node["@name"]}", "content": ${sri.renderSection(.node["@name"])}}</#macro> | ||
| 25 | |||
| 26 | <#-- ================ Containers ================ --> | ||
| 27 | <#macro container> | ||
| 28 | {"type": "container", "id": "${.node["@id"]!""}", "style": "${.node["@style"]!""}", "children": [<#recurse>]} | ||
| 29 | </#macro> | ||
| 30 | |||
| 31 | <#macro label> | ||
| 32 | {"type": "label", "text": "${ec.resource.expand(.node["@text"], "")?json_string}"} | ||
| 33 | </#macro> | ||
| 34 | |||
| 35 | <#macro link> | ||
| 36 | {"type": "link", "text": "${ec.resource.expand(.node["@text"]!"", "")?json_string}", "url": "${.node["@url"]!""}"} | ||
| 37 | </#macro> |
screen/macro/DefaultScreenMacros.mcp.ftl
0 → 100644
| 1 | <#-- | ||
| 2 | Moqui MCP Optimized Macros | ||
| 3 | Renders screens in Markdown format optimized for LLM consumption. | ||
| 4 | --> | ||
| 5 | |||
| 6 | <#include "DefaultScreenMacros.any.ftl"/> | ||
| 7 | |||
| 8 | <#macro @element></#macro> | ||
| 9 | |||
| 10 | <#macro screen><#recurse></#macro> | ||
| 11 | |||
| 12 | <#macro widgets> | ||
| 13 | <#recurse> | ||
| 14 | </#macro> | ||
| 15 | |||
| 16 | <#macro "fail-widgets"><#recurse></#macro> | ||
| 17 | |||
| 18 | <#-- ================ Subscreens ================ --> | ||
| 19 | <#macro "subscreens-menu"></#macro> | ||
| 20 | <#macro "subscreens-active">${sri.renderSubscreen()}</#macro> | ||
| 21 | <#macro "subscreens-panel">${sri.renderSubscreen()}</#macro> | ||
| 22 | |||
| 23 | <#-- ================ Section ================ --> | ||
| 24 | <#macro section>${sri.renderSection(.node["@name"])}</#macro> | ||
| 25 | <#macro "section-iterate">${sri.renderSection(.node["@name"])}</#macro> | ||
| 26 | <#macro "section-include">${sri.renderSectionInclude(.node)}</#macro> | ||
| 27 | |||
| 28 | <#-- ================ Containers ================ --> | ||
| 29 | <#macro container> | ||
| 30 | <#recurse> | ||
| 31 | </#macro> | ||
| 32 | |||
| 33 | <#macro "container-box"> | ||
| 34 | <#if .node["box-header"]?has_content>### <#recurse .node["box-header"][0]></#if> | ||
| 35 | <#if .node["box-body"]?has_content><#recurse .node["box-body"][0]></#if> | ||
| 36 | <#if .node["box-body-nopad"]?has_content><#recurse .node["box-body-nopad"][0]></#if> | ||
| 37 | </#macro> | ||
| 38 | |||
| 39 | <#macro "container-row"><#list .node["row-col"] as rowColNode><#recurse rowColNode></#list></#macro> | ||
| 40 | |||
| 41 | <#macro "container-panel"> | ||
| 42 | <#if .node["panel-header"]?has_content>### <#recurse .node["panel-header"][0]></#if> | ||
| 43 | <#if .node["panel-left"]?has_content><#recurse .node["panel-left"][0]></#if> | ||
| 44 | <#recurse .node["panel-center"][0]> | ||
| 45 | <#if .node["panel-right"]?has_content><#recurse .node["panel-right"][0]></#if> | ||
| 46 | <#if .node["panel-footer"]?has_content><#recurse .node["panel-footer"][0]></#if> | ||
| 47 | </#macro> | ||
| 48 | |||
| 49 | <#macro "container-dialog"> | ||
| 50 | [Button: ${ec.resource.expand(.node["@button-text"], "")}] | ||
| 51 | </#macro> | ||
| 52 | |||
| 53 | <#-- ================== Standalone Fields ==================== --> | ||
| 54 | <#macro link> | ||
| 55 | <#assign linkNode = .node> | ||
| 56 | <#if linkNode["@condition"]?has_content><#assign conditionResult = ec.getResource().condition(linkNode["@condition"], "")><#else><#assign conditionResult = true></#if> | ||
| 57 | <#if conditionResult> | ||
| 58 | <#assign urlInstance = sri.makeUrlByType(linkNode["@url"]!"", linkNode["@url-type"]!"transition", linkNode, "true")> | ||
| 59 | <#assign linkText = ""> | ||
| 60 | <#if linkNode["@text"]?has_content> | ||
| 61 | <#assign linkText = ec.getResource().expand(linkNode["@text"], "")> | ||
| 62 | <#elseif linkNode["@entity-name"]?has_content> | ||
| 63 | <#assign linkText = sri.getFieldEntityValue(linkNode)> | ||
| 64 | </#if> | ||
| 65 | <#if !linkText?has_content && .node?parent?node_name?ends_with("-field")> | ||
| 66 | <#assign linkText = sri.getFieldValueString(.node?parent?parent)> | ||
| 67 | </#if> | ||
| 68 | |||
| 69 | <#-- Convert path to dot notation for moqui_render_screen --> | ||
| 70 | <#assign fullPath = urlInstance.sui.fullPathNameList![]> | ||
| 71 | <#assign dotPath = ""> | ||
| 72 | <#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list> | ||
| 73 | |||
| 74 | <#assign paramStr = urlInstance.getParameterString()> | ||
| 75 | <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> | ||
| 76 | |||
| 77 | [${linkText}](${dotPath})<#t> | ||
| 78 | </#if> | ||
| 79 | </#macro> | ||
| 80 | |||
| 81 | <#macro image>![${.node["@alt"]!""}](${(.node["@url"]!"")})</#macro> | ||
| 82 | |||
| 83 | <#macro label> | ||
| 84 | <#assign text = ec.resource.expand(.node["@text"], "")> | ||
| 85 | <#assign type = .node["@type"]!"span"> | ||
| 86 | <#if type == "h1"># ${text} | ||
| 87 | <#elseif type == "h2">## ${text} | ||
| 88 | <#elseif type == "h3">### ${text} | ||
| 89 | <#elseif type == "p">${text} | ||
| 90 | <#else>${text}</#if> | ||
| 91 | </#macro> | ||
| 92 | |||
| 93 | <#-- ======================= Form ========================= --> | ||
| 94 | <#macro "form-single"> | ||
| 95 | <#assign formNode = sri.getFormNode(.node["@name"])> | ||
| 96 | <#assign mapName = formNode["@map"]!"fieldValues"> | ||
| 97 | <#t>${sri.pushSingleFormMapContext(mapName)} | ||
| 98 | <#list formNode["field"] as fieldNode> | ||
| 99 | <#assign fieldSubNode = ""> | ||
| 100 | <#list fieldNode["conditional-field"] as csf><#if ec.resource.condition(csf["@condition"], "")><#assign fieldSubNode = csf><#break></#if></#list> | ||
| 101 | <#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if> | ||
| 102 | <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true"> | ||
| 103 | <#assign title><@fieldTitle fieldSubNode/></#assign> | ||
| 104 | * **${title}**: <#recurse fieldSubNode> | ||
| 105 | </#if> | ||
| 106 | </#list> | ||
| 107 | <#t>${sri.popContext()} | ||
| 108 | </#macro> | ||
| 109 | |||
| 110 | <#macro "form-list"> | ||
| 111 | <#assign formInstance = sri.getFormInstance(.node["@name"])> | ||
| 112 | <#assign formListInfo = formInstance.makeFormListRenderInfo()> | ||
| 113 | <#assign formNode = formListInfo.getFormNode()> | ||
| 114 | <#assign formListColumnList = formListInfo.getAllColInfo()> | ||
| 115 | <#assign listObject = formListInfo.getListObject(false)!> | ||
| 116 | |||
| 117 | <#-- Header Row --> | ||
| 118 | <#list formListColumnList as columnFieldList> | ||
| 119 | <#assign fieldNode = columnFieldList[0]> | ||
| 120 | <#assign fieldSubNode = fieldNode["header-field"][0]!fieldNode["default-field"][0]!fieldNode["conditional-field"][0]!> | ||
| 121 | <#t>| <@fieldTitle fieldSubNode/><#t> | ||
| 122 | </#list> | ||
| 123 | | | ||
| 124 | <#list formListColumnList as columnFieldList>| --- </#list>| | ||
| 125 | <#-- Data Rows --> | ||
| 126 | <#list listObject as listEntry> | ||
| 127 | <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)} | ||
| 128 | <#list formListColumnList as columnFieldList> | ||
| 129 | <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t> | ||
| 130 | </#list> | ||
| 131 | | | ||
| 132 | <#t>${sri.endFormListRow()} | ||
| 133 | </#list> | ||
| 134 | <#t>${sri.safeCloseList(listObject)} | ||
| 135 | </#macro> | ||
| 136 | |||
| 137 | <#macro formListSubField fieldNode> | ||
| 138 | <#list fieldNode["conditional-field"] as fieldSubNode> | ||
| 139 | <#if ec.resource.condition(fieldSubNode["@condition"], "")> | ||
| 140 | <#t><@formListWidget fieldSubNode/> | ||
| 141 | <#return> | ||
| 142 | </#if> | ||
| 143 | </#list> | ||
| 144 | <#if fieldNode["default-field"]?has_content> | ||
| 145 | <#t><@formListWidget fieldNode["default-field"][0]/> | ||
| 146 | </#if> | ||
| 147 | </#macro> | ||
| 148 | |||
| 149 | <#macro formListWidget fieldSubNode> | ||
| 150 | <#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode["submit"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if> | ||
| 151 | <#recurse fieldSubNode> | ||
| 152 | </#macro> | ||
| 153 | |||
| 154 | <#macro fieldTitle fieldSubNode> | ||
| 155 | <#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> | ||
| 156 | <#t>${ec.l10n.localize(titleValue)} | ||
| 157 | </#macro> | ||
| 158 | |||
| 159 | <#-- ================== Form Field Widgets ==================== --> | ||
| 160 | <#macro "check"> | ||
| 161 | <#assign options = sri.getFieldOptions(.node)!> | ||
| 162 | <#assign currentValue = sri.getFieldValueString(.node)> | ||
| 163 | <#t>${(options.get(currentValue))!(currentValue)} | ||
| 164 | </#macro> | ||
| 165 | |||
| 166 | <#macro "date-find"></#macro> | ||
| 167 | <#macro "date-time"> | ||
| 168 | <#assign javaFormat = .node["@format"]!> | ||
| 169 | <#if !javaFormat?has_content> | ||
| 170 | <#if .node["@type"]! == "time"><#assign javaFormat="HH:mm"> | ||
| 171 | <#elseif .node["@type"]! == "date"><#assign javaFormat="yyyy-MM-dd"> | ||
| 172 | <#else><#assign javaFormat="yyyy-MM-dd HH:mm"></#if> | ||
| 173 | </#if> | ||
| 174 | <#assign fieldValue = sri.getFieldValueString(.node?parent?parent, .node["@default-value"]!"", javaFormat)> | ||
| 175 | <#t>${fieldValue} | ||
| 176 | </#macro> | ||
| 177 | |||
| 178 | <#macro "display"> | ||
| 179 | <#assign fieldValue = ""> | ||
| 180 | <#assign dispFieldNode = .node?parent?parent> | ||
| 181 | <#if .node["@text"]?has_content> | ||
| 182 | <#assign textMap = {}> | ||
| 183 | <#if .node["@text-map"]?has_content><#assign textMap = ec.getResource().expression(.node["@text-map"], {})!></#if> | ||
| 184 | <#assign fieldValue = ec.getResource().expand(.node["@text"], "", textMap, false)> | ||
| 185 | <#if .node["@currency-unit-field"]?has_content> | ||
| 186 | <#assign fieldValue = ec.getL10n().formatCurrency(fieldValue, ec.getResource().expression(.node["@currency-unit-field"], ""))> | ||
| 187 | </#if> | ||
| 188 | <#else> | ||
| 189 | <#assign fieldValue = sri.getFieldValueString(.node)> | ||
| 190 | </#if> | ||
| 191 | <#t>${fieldValue} | ||
| 192 | </#macro> | ||
| 193 | |||
| 194 | <#macro "display-entity"> | ||
| 195 | <#t>${sri.getFieldEntityValue(.node)} | ||
| 196 | </#macro> | ||
| 197 | |||
| 198 | <#macro "drop-down"> | ||
| 199 | <#assign options = sri.getFieldOptions(.node)> | ||
| 200 | <#assign currentValue = sri.getFieldValueString(.node)> | ||
| 201 | <#t>${(options.get(currentValue))!(currentValue)} | ||
| 202 | </#macro> | ||
| 203 | |||
| 204 | <#macro "text-area"><#t>${sri.getFieldValueString(.node)}</#macro> | ||
| 205 | <#macro "text-line"><#t>${sri.getFieldValueString(.node)}</#macro> | ||
| 206 | <#macro "text-find"><#t>${sri.getFieldValueString(.node)}</#macro> | ||
| 207 | <#macro "submit"></#macro> | ||
| 208 | <#macro "password"></#macro> | ||
| 209 | <#macro "hidden"></#macro> |
| ... | @@ -142,7 +142,7 @@ | ... | @@ -142,7 +142,7 @@ |
| 142 | if (name == "moqui_render_screen") { | 142 | if (name == "moqui_render_screen") { |
| 143 | def screenPath = arguments?.path | 143 | def screenPath = arguments?.path |
| 144 | def parameters = arguments?.parameters ?: [:] | 144 | def parameters = arguments?.parameters ?: [:] |
| 145 | def renderMode = arguments?.renderMode ?: "html" | 145 | def renderMode = arguments?.renderMode ?: "mcp" |
| 146 | def subscreenName = arguments?.subscreenName | 146 | def subscreenName = arguments?.subscreenName |
| 147 | 147 | ||
| 148 | if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter") | 148 | if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter") |
| ... | @@ -257,7 +257,7 @@ | ... | @@ -257,7 +257,7 @@ |
| 257 | timestamp: System.currentTimeMillis() | 257 | timestamp: System.currentTimeMillis() |
| 258 | ] | 258 | ] |
| 259 | ] | 259 | ] |
| 260 | servlet.queueNotification(sessionId, notification) | 260 | //servlet.queueNotification(sessionId, notification) |
| 261 | } | 261 | } |
| 262 | } catch (Exception e) { | 262 | } catch (Exception e) { |
| 263 | ec.logger.warn("Failed to send tool execution notification: ${e.message}") | 263 | ec.logger.warn("Failed to send tool execution notification: ${e.message}") |
| ... | @@ -368,13 +368,24 @@ | ... | @@ -368,13 +368,24 @@ |
| 368 | // Query entity data | 368 | // Query entity data |
| 369 | def entityList = ec.entity.find(entityName).limit(100).list() | 369 | def entityList = ec.entity.find(entityName).limit(100).list() |
| 370 | 370 | ||
| 371 | // Format response | 371 | // Format response for MCP - create multiple content objects |
| 372 | def contentList = [] | ||
| 373 | |||
| 374 | // Add main content with entity data as text | ||
| 375 | contentList << [ | ||
| 376 | type: "text", | ||
| 377 | text: new JsonBuilder([ | ||
| 378 | entityName: entityName, | ||
| 379 | description: entityDef.description, | ||
| 380 | packageName: entityDef.packageName, | ||
| 381 | recordCount: entityList.size(), | ||
| 382 | data: entityList | ||
| 383 | ]).toString() | ||
| 384 | ] | ||
| 385 | |||
| 372 | def responseMap = [ | 386 | def responseMap = [ |
| 373 | entityName: entityName, | 387 | content: contentList, |
| 374 | description: entityDef.description, | 388 | isError: false |
| 375 | packageName: entityDef.packageName, | ||
| 376 | recordCount: entityList.size(), | ||
| 377 | data: entityList | ||
| 378 | ] | 389 | ] |
| 379 | 390 | ||
| 380 | def jsonOutput = new JsonBuilder(responseMap).toString() | 391 | def jsonOutput = new JsonBuilder(responseMap).toString() |
| ... | @@ -596,7 +607,7 @@ | ... | @@ -596,7 +607,7 @@ |
| 596 | <in-parameters> | 607 | <in-parameters> |
| 597 | <parameter name="screenPath" required="true"/> | 608 | <parameter name="screenPath" required="true"/> |
| 598 | <parameter name="parameters" type="Map"><description>Parameters to pass to the screen</description></parameter> | 609 | <parameter name="parameters" type="Map"><description>Parameters to pass to the screen</description></parameter> |
| 599 | <parameter name="renderMode" default="html"><description>Render mode: text, html, xml, vuet, qvt</description></parameter> | 610 | <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> |
| 600 | <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> | 611 | <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> |
| 601 | <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter> | 612 | <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter> |
| 602 | </in-parameters> | 613 | </in-parameters> |
| ... | @@ -704,7 +715,7 @@ def startTime = System.currentTimeMillis() | ... | @@ -704,7 +715,7 @@ def startTime = System.currentTimeMillis() |
| 704 | // Regular screen rendering with current user context - use our custom ScreenTestImpl | 715 | // Regular screen rendering with current user context - use our custom ScreenTestImpl |
| 705 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) | 716 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) |
| 706 | .rootScreen(rootScreen) | 717 | .rootScreen(rootScreen) |
| 707 | .renderMode(renderMode ? renderMode : "html") | 718 | .renderMode(renderMode ? renderMode : "mcp") |
| 708 | .auth(ec.user.username) | 719 | .auth(ec.user.username) |
| 709 | 720 | ||
| 710 | def renderParams = parameters ?: [:] | 721 | def renderParams = parameters ?: [:] |
| ... | @@ -1254,7 +1265,7 @@ def startTime = System.currentTimeMillis() | ... | @@ -1254,7 +1265,7 @@ def startTime = System.currentTimeMillis() |
| 1254 | properties: [ | 1265 | properties: [ |
| 1255 | path: [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], | 1266 | path: [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], |
| 1256 | parameters: [type: "object", description: "Parameters for the screen"], | 1267 | parameters: [type: "object", description: "Parameters for the screen"], |
| 1257 | renderMode: [type: "string", description: "html, text, or json", default: "html"] | 1268 | renderMode: [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"] |
| 1258 | ], | 1269 | ], |
| 1259 | required: ["path"] | 1270 | required: ["path"] |
| 1260 | ] | 1271 | ] | ... | ... |
| ... | @@ -295,6 +295,10 @@ class CustomScreenTestImpl implements McpScreenTest { | ... | @@ -295,6 +295,10 @@ class CustomScreenTestImpl implements McpScreenTest { |
| 295 | org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath) | 295 | org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath) |
| 296 | // set stub on eci, will also put parameters in the context | 296 | // set stub on eci, will also put parameters in the context |
| 297 | eci.setWebFacade(wfs) | 297 | eci.setWebFacade(wfs) |
| 298 | |||
| 299 | // Put web facade objects in context for screen access | ||
| 300 | cs.put("html_scripts", wfs.getHtmlScripts()) | ||
| 301 | cs.put("html_stylesheets", wfs.getHtmlStyleSheets()) | ||
| 298 | // make the ScreenRender | 302 | // make the ScreenRender |
| 299 | ScreenRender screenRender = csti.sfi.makeRender() | 303 | ScreenRender screenRender = csti.sfi.makeRender() |
| 300 | stri.screenRender = screenRender | 304 | stri.screenRender = screenRender | ... | ... |
| ... | @@ -228,7 +228,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -228,7 +228,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 228 | if ("GET".equals(method) && requestURI.endsWith("/sse")) { | 228 | if ("GET".equals(method) && requestURI.endsWith("/sse")) { |
| 229 | handleSseConnection(request, response, ec, webappName) | 229 | handleSseConnection(request, response, ec, webappName) |
| 230 | } else if ("POST".equals(method) && requestURI.endsWith("/message")) { | 230 | } else if ("POST".equals(method) && requestURI.endsWith("/message")) { |
| 231 | handleMessage(request, response, ec) | 231 | handleMessage(request, response, ec, requestBody) |
| 232 | } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { | 232 | } else if ("POST".equals(method) && (requestURI.equals("/mcp") || requestURI.endsWith("/mcp"))) { |
| 233 | // Handle POST requests to /mcp for JSON-RPC | 233 | // Handle POST requests to /mcp for JSON-RPC |
| 234 | logger.info("About to call handleJsonRpc with visit: ${visit?.visitId}") | 234 | logger.info("About to call handleJsonRpc with visit: ${visit?.visitId}") |
| ... | @@ -245,11 +245,8 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -245,11 +245,8 @@ class EnhancedMcpServlet extends HttpServlet { |
| 245 | logger.warn("Enhanced MCP Access Forbidden (no authz): " + e.message) | 245 | logger.warn("Enhanced MCP Access Forbidden (no authz): " + e.message) |
| 246 | response.setStatus(HttpServletResponse.SC_FORBIDDEN) | 246 | response.setStatus(HttpServletResponse.SC_FORBIDDEN) |
| 247 | response.setContentType("application/json") | 247 | response.setContentType("application/json") |
| 248 | response.writer.write(JsonOutput.toJson([ | 248 | def msg = e.message?.toString() ?: "Access forbidden" |
| 249 | jsonrpc: "2.0", | 249 | response.writer.write("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32001,\"message\":\"Access Forbidden: ${msg.replace("\"", "\\\"")}\"},\"id\":null}") |
| 250 | error: [code: -32001, message: "Access Forbidden: " + e.message], | ||
| 251 | id: null | ||
| 252 | ])) | ||
| 253 | } catch (ArtifactTarpitException e) { | 250 | } catch (ArtifactTarpitException e) { |
| 254 | logger.warn("Enhanced MCP Too Many Requests (tarpit): " + e.message) | 251 | logger.warn("Enhanced MCP Too Many Requests (tarpit): " + e.message) |
| 255 | response.setStatus(429) | 252 | response.setStatus(429) |
| ... | @@ -266,11 +263,9 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -266,11 +263,9 @@ class EnhancedMcpServlet extends HttpServlet { |
| 266 | logger.error("Error in Enhanced MCP request", t) | 263 | logger.error("Error in Enhanced MCP request", t) |
| 267 | response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) | 264 | response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) |
| 268 | response.setContentType("application/json") | 265 | response.setContentType("application/json") |
| 269 | response.writer.write(JsonOutput.toJson([ | 266 | // Use simple JSON string to avoid Groovy JSON library issues |
| 270 | jsonrpc: "2.0", | 267 | def errorMsg = t.message?.toString() ?: "Unknown error" |
| 271 | error: [code: -32603, message: "Internal error: " + t.message], | 268 | response.writer.write("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error: ${errorMsg.replace("\"", "\\\"")}\"},\"id\":null}") |
| 272 | id: null | ||
| 273 | ])) | ||
| 274 | } finally { | 269 | } finally { |
| 275 | ec.destroy() | 270 | ec.destroy() |
| 276 | } | 271 | } |
| ... | @@ -337,6 +332,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -337,6 +332,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 337 | response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "System configuration error: Web facade failed to initialize. Check Moqui logs for details.") | 332 | response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "System configuration error: Web facade failed to initialize. Check Moqui logs for details.") |
| 338 | return | 333 | return |
| 339 | } | 334 | } |
| 335 | } | ||
| 340 | 336 | ||
| 341 | // Final check that we have a Visit | 337 | // Final check that we have a Visit |
| 342 | if (!visit) { | 338 | if (!visit) { |
| ... | @@ -433,10 +429,20 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -433,10 +429,20 @@ class EnhancedMcpServlet extends HttpServlet { |
| 433 | request.getAsyncContext().complete() | 429 | request.getAsyncContext().complete() |
| 434 | } catch (Exception e) { | 430 | } catch (Exception e) { |
| 435 | logger.debug("Error completing async context: ${e.message}") | 431 | logger.debug("Error completing async context: ${e.message}") |
| 436 | } | ||
| 437 | } | 432 | } |
| 438 | } | 433 | } |
| 439 | } | 434 | } |
| 435 | } | ||
| 436 | |||
| 437 | private void handleMessage(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec, String requestBody) | ||
| 438 | throws IOException { | ||
| 439 | |||
| 440 | String sessionId = request.getHeader("Mcp-Session-Id") | ||
| 441 | def visit = getCachedVisit(ec, sessionId) | ||
| 442 | if (!visit) { | ||
| 443 | response.sendError(HttpServletResponse.SC_NOT_FOUND, "Session not found: " + sessionId) | ||
| 444 | return | ||
| 445 | } | ||
| 440 | 446 | ||
| 441 | // Verify user has access to this Visit - rely on Moqui security | 447 | // Verify user has access to this Visit - rely on Moqui security |
| 442 | logger.info("Session validation: visit.userId=${visit.userId}, ec.user.userId=${ec.user.userId}, ec.user.username=${ec.user.username}") | 448 | logger.info("Session validation: visit.userId=${visit.userId}, ec.user.userId=${ec.user.userId}, ec.user.username=${ec.user.username}") |
| ... | @@ -456,29 +462,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -456,29 +462,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 456 | VisitBasedMcpSession session = new VisitBasedMcpSession(visit, response.writer, ec) | 462 | VisitBasedMcpSession session = new VisitBasedMcpSession(visit, response.writer, ec) |
| 457 | 463 | ||
| 458 | try { | 464 | try { |
| 459 | // Read request body | 465 | if (!requestBody || !requestBody.trim()) { |
| 460 | StringBuilder body = new StringBuilder() | ||
| 461 | try { | ||
| 462 | BufferedReader reader = request.getReader() | ||
| 463 | String line | ||
| 464 | while ((line = reader.readLine()) != null) { | ||
| 465 | body.append(line) | ||
| 466 | } | ||
| 467 | } catch (IOException e) { | ||
| 468 | logger.error("Failed to read request body: ${e.message}") | ||
| 469 | response.setContentType("application/json") | ||
| 470 | response.setCharacterEncoding("UTF-8") | ||
| 471 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | ||
| 472 | response.writer.write(JsonOutput.toJson([ | ||
| 473 | jsonrpc: "2.0", | ||
| 474 | error: [code: -32700, message: "Failed to read request body: " + e.message], | ||
| 475 | id: null | ||
| 476 | ])) | ||
| 477 | return | ||
| 478 | } | ||
| 479 | |||
| 480 | String requestBody = body.toString() | ||
| 481 | if (!requestBody.trim()) { | ||
| 482 | response.setContentType("application/json") | 466 | response.setContentType("application/json") |
| 483 | response.setCharacterEncoding("UTF-8") | 467 | response.setCharacterEncoding("UTF-8") |
| 484 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | 468 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) |
| ... | @@ -572,9 +556,8 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -572,9 +556,8 @@ class EnhancedMcpServlet extends HttpServlet { |
| 572 | 556 | ||
| 573 | String method = request.getMethod() | 557 | String method = request.getMethod() |
| 574 | String acceptHeader = request.getHeader("Accept") | 558 | String acceptHeader = request.getHeader("Accept") |
| 575 | String contentType = request.getContentType() | ||
| 576 | 559 | ||
| 577 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}") | 560 | logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}") |
| 578 | 561 | ||
| 579 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 | 562 | // Validate Accept header per MCP 2025-11-25 spec requirement #2 |
| 580 | // Client MUST include Accept header listing both application/json and text/event-stream | 563 | // Client MUST include Accept header listing both application/json and text/event-stream |
| ... | @@ -583,7 +566,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -583,7 +566,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 583 | response.setContentType("application/json") | 566 | response.setContentType("application/json") |
| 584 | response.writer.write(JsonOutput.toJson([ | 567 | response.writer.write(JsonOutput.toJson([ |
| 585 | jsonrpc: "2.0", | 568 | jsonrpc: "2.0", |
| 586 | error: [code: -32600, message: "Accept header must include both application/json and text/event-stream per MCP 2025-11-25 spec"], | 569 | error: [code: -32600, message: "Accept header must include application/json and text/event-stream"], |
| 587 | id: null | 570 | id: null |
| 588 | ])) | 571 | ])) |
| 589 | return | 572 | return |
| ... | @@ -594,28 +577,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -594,28 +577,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 594 | response.setContentType("application/json") | 577 | response.setContentType("application/json") |
| 595 | response.writer.write(JsonOutput.toJson([ | 578 | response.writer.write(JsonOutput.toJson([ |
| 596 | jsonrpc: "2.0", | 579 | jsonrpc: "2.0", |
| 597 | error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."], | 580 | error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC."], |
| 598 | id: null | ||
| 599 | ])) | ||
| 600 | return | ||
| 601 | } | ||
| 602 | |||
| 603 | // Use pre-read request body | ||
| 604 | logger.info("Using pre-read request body, length: ${requestBody?.length()}") | ||
| 605 | |||
| 606 | String jsonMethod = request.getMethod() | ||
| 607 | String jsonAcceptHeader = request.getHeader("Accept") | ||
| 608 | String jsonContentType = request.getContentType() | ||
| 609 | |||
| 610 | logger.info("Enhanced MCP JSON-RPC Request: ${jsonMethod} ${request.requestURI} - Accept: ${jsonAcceptHeader}, Content-Type: ${jsonContentType}") | ||
| 611 | |||
| 612 | // Handle POST requests for JSON-RPC | ||
| 613 | if (!"POST".equals(jsonMethod)) { | ||
| 614 | response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) | ||
| 615 | response.setContentType("application/json") | ||
| 616 | response.writer.write(JsonOutput.toJson([ | ||
| 617 | jsonrpc: "2.0", | ||
| 618 | error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."], | ||
| 619 | id: null | 581 | id: null |
| 620 | ])) | 582 | ])) |
| 621 | return | 583 | return |
| ... | @@ -774,6 +736,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -774,6 +736,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 774 | if (sessionId) { | 736 | if (sessionId) { |
| 775 | response.setHeader("Mcp-Session-Id", sessionId.toString()) | 737 | response.setHeader("Mcp-Session-Id", sessionId.toString()) |
| 776 | } | 738 | } |
| 739 | response.setContentType("text/event-stream") | ||
| 777 | response.setStatus(HttpServletResponse.SC_ACCEPTED) // 202 Accepted | 740 | response.setStatus(HttpServletResponse.SC_ACCEPTED) // 202 Accepted |
| 778 | logger.info("Sent 202 Accepted response for notifications/initialized") | 741 | logger.info("Sent 202 Accepted response for notifications/initialized") |
| 779 | response.flushBuffer() // Commit the response immediately | 742 | response.flushBuffer() // Commit the response immediately |
| ... | @@ -817,11 +780,6 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -817,11 +780,6 @@ class EnhancedMcpServlet extends HttpServlet { |
| 817 | logger.info("Set Mcp-Session-Id header to ${responseSessionId} for method ${rpcRequest.method}") | 780 | logger.info("Set Mcp-Session-Id header to ${responseSessionId} for method ${rpcRequest.method}") |
| 818 | } | 781 | } |
| 819 | 782 | ||
| 820 | if (responseSessionId) { | ||
| 821 | response.setHeader("Mcp-Session-Id", responseSessionId) | ||
| 822 | logger.info("Set Mcp-Session-Id header to ${responseSessionId} for method ${rpcRequest.method}") | ||
| 823 | } | ||
| 824 | |||
| 825 | // Build JSON-RPC response for regular requests | 783 | // Build JSON-RPC response for regular requests |
| 826 | // Extract the actual result from Moqui service response | 784 | // Extract the actual result from Moqui service response |
| 827 | def actualResult = result?.result ?: result | 785 | def actualResult = result?.result ?: result |
| ... | @@ -841,9 +799,8 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -841,9 +799,8 @@ class EnhancedMcpServlet extends HttpServlet { |
| 841 | def notificationContent = [] | 799 | def notificationContent = [] |
| 842 | for (notification in pendingNotifications) { | 800 | for (notification in pendingNotifications) { |
| 843 | notificationContent << [ | 801 | notificationContent << [ |
| 844 | type: "notification", | 802 | type: "text", |
| 845 | text: JsonOutput.toJson(notification.params ?: notification), | 803 | text: "Notification [${notification.method}]: " + JsonOutput.toJson(notification.params ?: notification) |
| 846 | method: notification.method | ||
| 847 | ] | 804 | ] |
| 848 | } | 805 | } |
| 849 | 806 | ||
| ... | @@ -1151,7 +1108,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -1151,7 +1108,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 1151 | method: notification.method ?: "notifications/message", | 1108 | method: notification.method ?: "notifications/message", |
| 1152 | params: notification.params ?: notification | 1109 | params: notification.params ?: notification |
| 1153 | ] | 1110 | ] |
| 1154 | sendSseEvent(writer, "notification", JsonOutput.toJson(notificationMessage), System.currentTimeMillis()) | 1111 | sendSseEvent(writer, "message", JsonOutput.toJson(notificationMessage), System.currentTimeMillis()) |
| 1155 | logger.info("Sent notification via SSE to session ${sessionId}") | 1112 | logger.info("Sent notification via SSE to session ${sessionId}") |
| 1156 | } catch (Exception e) { | 1113 | } catch (Exception e) { |
| 1157 | logger.warn("Failed to send notification via SSE to session ${sessionId}: ${e.message}") | 1114 | logger.warn("Failed to send notification via SSE to session ${sessionId}: ${e.message}") |
| ... | @@ -1270,7 +1227,7 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -1270,7 +1227,7 @@ class EnhancedMcpServlet extends HttpServlet { |
| 1270 | PrintWriter writer = activeConnections.get(visit.visitId) | 1227 | PrintWriter writer = activeConnections.get(visit.visitId) |
| 1271 | if (writer && !writer.checkError()) { | 1228 | if (writer && !writer.checkError()) { |
| 1272 | try { | 1229 | try { |
| 1273 | sendSseEvent(writer, "broadcast", message.toJson()) | 1230 | sendSseEvent(writer, "message", message.toJson()) |
| 1274 | successCount++ | 1231 | successCount++ |
| 1275 | } catch (Exception e) { | 1232 | } catch (Exception e) { |
| 1276 | logger.warn("Failed to send broadcast to ${visit.visitId}: ${e.message}") | 1233 | logger.warn("Failed to send broadcast to ${visit.visitId}: ${e.message}") | ... | ... |
| ... | @@ -54,6 +54,10 @@ class WebFacadeStub implements WebFacade { | ... | @@ -54,6 +54,10 @@ class WebFacadeStub implements WebFacade { |
| 54 | 54 | ||
| 55 | protected List<Map> screenHistory = [] | 55 | protected List<Map> screenHistory = [] |
| 56 | 56 | ||
| 57 | // Web context objects needed by screens | ||
| 58 | protected Set<String> html_scripts = new LinkedHashSet<>() | ||
| 59 | protected Set<String> html_stylesheets = new LinkedHashSet<>() | ||
| 60 | |||
| 57 | protected String responseText = null | 61 | protected String responseText = null |
| 58 | protected Object responseJsonObj = null | 62 | protected Object responseJsonObj = null |
| 59 | boolean skipJsonSerialize = false | 63 | boolean skipJsonSerialize = false |
| ... | @@ -221,6 +225,10 @@ class WebFacadeStub implements WebFacade { | ... | @@ -221,6 +225,10 @@ class WebFacadeStub implements WebFacade { |
| 221 | @Override | 225 | @Override |
| 222 | List<Map> getScreenHistory() { return screenHistory } | 226 | List<Map> getScreenHistory() { return screenHistory } |
| 223 | 227 | ||
| 228 | List<String> getHtmlScripts() { return new ArrayList<>(html_scripts) } | ||
| 229 | |||
| 230 | List<String> getHtmlStyleSheets() { return new ArrayList<>(html_stylesheets) } | ||
| 231 | |||
| 224 | @Override | 232 | @Override |
| 225 | void sendJsonResponse(Object responseObj) { | 233 | void sendJsonResponse(Object responseObj) { |
| 226 | if (!skipJsonSerialize) { | 234 | if (!skipJsonSerialize) { | ... | ... |
-
Please register or sign in to post a comment