Refactor MCP services and adopt slash-based screen paths
Extract core MCP logic into modular services (ResolveScreenPath, RenderScreenNarrative, ExecuteScreenAction) and update screen path conventions to use slash notation (e.g., /PopCommerce/Product) instead of dot notation. This aligns MCP navigation with browser URLs and improves path resolution reliability. - Split McpServices.xml into specialized services for better maintainability - Update DefaultScreenMacros.mcp.ftl to generate slash-based links - Update prompts and documentation to reflect new path convention - Enhance CustomScreenTestImpl to support slash path parsing - Add AGENTS.md documenting self-guided narrative screens architecture
Showing
9 changed files
with
1103 additions
and
667 deletions
AGENTS.md
0 → 100644
| 1 | # Moqui MCP Self-Guided Narrative Screens | ||
| 2 | |||
| 3 | ## 🎯 Core Goal | ||
| 4 | |||
| 5 | Enable ANY AI/LLM model to autonomously navigate Moqui ERP and perform business tasks through **self-guided narrative screens** that provide: | ||
| 6 | - Clear description of current state | ||
| 7 | - Available actions with exact invocation examples | ||
| 8 | - Navigation guidance for related screens | ||
| 9 | - Contextual notes for constraints and next steps | ||
| 10 | |||
| 11 | The interface is **model-agnostic** - works with GPT, Claude, local models, or any other AI agent. | ||
| 12 | |||
| 13 | --- | ||
| 14 | |||
| 15 | ## 🧩 How Models Use the Interface | ||
| 16 | |||
| 17 | ### Discovery Workflow | ||
| 18 | ``` | ||
| 19 | 1. moqui_browse_screens(path="") → See available screens | ||
| 20 | 2. moqui_get_screen_details(path="/PopCommerce/Catalog/Product") → Understand parameters | ||
| 21 | 3. moqui_render_screen(path="/PopCommerce/Catalog/Product/FindProduct", parameters={name: "blue widget"}) → Execute with context | ||
| 22 | ``` | ||
| 23 | |||
| 24 | ### Navigation Pattern | ||
| 25 | ``` | ||
| 26 | AI receives: "Find blue products in catalog" | ||
| 27 | → Browse to /PopCommerce/Catalog | ||
| 28 | → See subscreen: Product/FindProduct | ||
| 29 | → uiNarrative.actions: "To search products, use moqui_render_screen(path='/PopCommerce/Catalog/Product/FindProduct', parameters={productName: 'blue'})" | ||
| 30 | → AI executes exactly as guided | ||
| 31 | ``` | ||
| 32 | |||
| 33 | ### Action Execution Pattern | ||
| 34 | ``` | ||
| 35 | AI receives: "Update PROD-001 price to $35.99" | ||
| 36 | → Browse to /PopCommerce/Catalog/Product/EditPrices | ||
| 37 | → uiNarrative.actions: "To update price, call with action='update', parameters={productId: 'PROD-001', price: 35.99}" | ||
| 38 | → AI executes transition | ||
| 39 | → Receives confirmation | ||
| 40 | → Reports completion | ||
| 41 | ``` | ||
| 42 | |||
| 43 | --- | ||
| 44 | |||
| 45 | ## 🔧 Near-Term Fixes (Required for Generic Model Access) | ||
| 46 | |||
| 47 | ### ✅ 1. Path Delimiter Change (COMPLETED) | ||
| 48 | **Goal**: Change from `.` to `/` to match browser URLs | ||
| 49 | **Impact**: More intuitive, matches what users see in browser | ||
| 50 | **Priority**: High | ||
| 51 | |||
| 52 | **Files modified**: | ||
| 53 | - ✅ `service/McpServices.xml` - Path resolution logic (line 206) - Now supports both `.` and `/` | ||
| 54 | - ✅ `screen/macro/DefaultScreenMacros.mcp.ftl` - Link rendering (line 70) - Links now use `/` | ||
| 55 | - ✅ `data/McpScreenDocsData.xml` - All documentation examples updated to `/` | ||
| 56 | - ✅ `data/McpPromptsData.xml` - Prompt examples updated to `/` | ||
| 57 | |||
| 58 | **Changes made**: | ||
| 59 | - Split path on `/` first, fallback to `.` for backward compatibility | ||
| 60 | - Updated all navigation links in macros to use `/` delimiter | ||
| 61 | - Updated all wiki documentation to use `/` format | ||
| 62 | |||
| 63 | **Backward compatibility**: Both `.` and `/` delimiters work during transition period | ||
| 64 | |||
| 65 | ### ✅ 2. Screen Path Resolution Fix (COMPLETED) | ||
| 66 | **Problem**: PopCommerce screens return empty responses | ||
| 67 | **Impact**: ANY model cannot access core business screens | ||
| 68 | **Priority**: Critical | ||
| 69 | |||
| 70 | **Files modified**: | ||
| 71 | - ✅ `service/McpServices.xml` (lines 1521-1545) - Fixed Admin vs Root fallback | ||
| 72 | - ✅ `service/McpServices.xml` (lines 952-962) - Better error messages for navigation failures | ||
| 73 | |||
| 74 | **Changes made**: | ||
| 75 | - Added debug logging for all path resolution attempts | ||
| 76 | - Fixed fallback logic to try PopCommerceAdmin first, then PopCommerceRoot | ||
| 77 | - Added specific error messages when navigation fails | ||
| 78 | - Added logging of available subscreens on failure | ||
| 79 | |||
| 80 | **Validation required**: | ||
| 81 | - [ ] PopCommerce.PopCommerceAdmin screens render with data (test with server running) | ||
| 82 | - [ ] PopCommerce.PopCommerceRoot screens render with data | ||
| 83 | - [ ] Error messages show which screen failed and why | ||
| 84 | - [ ] Deep screens (FindProduct, EditPrices) render correctly | ||
| 85 | |||
| 86 | ### ✅ 3. Dynamic Service Name Resolution (COMPLETED) | ||
| 87 | **Problem**: Hardcoded to `mantle.product.ProductPrice` | ||
| 88 | **Impact**: ANY model limited to pricing, cannot create orders/customers | ||
| 89 | **Priority**: Critical | ||
| 90 | |||
| 91 | **Files modified**: | ||
| 92 | - ✅ `service/McpServices.xml` (lines 1649-1712) - Dynamic service extraction from transitions | ||
| 93 | |||
| 94 | **Changes made**: | ||
| 95 | - Extract service names from transition definitions dynamically | ||
| 96 | - Fallback to convention only when transition has no service | ||
| 97 | - Added logging of found transitions | ||
| 98 | - Added error message when service not found | ||
| 99 | |||
| 100 | **Validation required**: | ||
| 101 | - [ ] Price updates work (ProductPrice) | ||
| 102 | - [ ] Order creation works (mantle.order.Order) | ||
| 103 | - [ ] Customer creation works (mantle.party.Party) | ||
| 104 | - [ ] All entity types supported dynamically | ||
| 105 | |||
| 106 | ### ✅ 4. Parameter Validation (COMPLETED) | ||
| 107 | **Problem**: Actions execute without checking requirements | ||
| 108 | **Impact**: ANY model receives cryptic errors on invalid calls | ||
| 109 | **Priority**: High | ||
| 110 | |||
| 111 | **Files modified**: | ||
| 112 | - ✅ `service/McpServices.xml` (lines 1667-1712, 1703-1731) - Validation before service calls | ||
| 113 | |||
| 114 | **Changes made**: | ||
| 115 | - Added service definition lookup before execution | ||
| 116 | - Validate all required parameters exist (collects ALL missing params) | ||
| 117 | - Return clear error message listing all missing parameters | ||
| 118 | - Log which parameters are missing for debugging | ||
| 119 | |||
| 120 | **Validation required**: | ||
| 121 | - [ ] Missing parameters return clear error before execution | ||
| 122 | - [ ] All missing parameters listed together in error message | ||
| 123 | - [ ] Optional parameters still work | ||
| 124 | - [ ] No silent failures | ||
| 125 | |||
| 126 | ### ✅ 5. Transition Metadata Enhancement (COMPLETED) | ||
| 127 | **Problem**: Only name and service captured, missing requirements | ||
| 128 | **Impact**: ANY model doesn't know what parameters are needed | ||
| 129 | **Priority**: High | ||
| 130 | |||
| 131 | **Files modified**: | ||
| 132 | - ✅ `service/McpServices.xml` (lines 1000-1017) - Enhanced action metadata | ||
| 133 | |||
| 134 | **Changes made**: | ||
| 135 | - Extract full service definitions from transition | ||
| 136 | - Include parameter names, types, and required flags | ||
| 137 | - Add parameter details to action metadata | ||
| 138 | - Handle cases where service has no parameters | ||
| 139 | |||
| 140 | **Validation required**: | ||
| 141 | - [ ] Actions include parameter names and types | ||
| 142 | - [ ] Required/optional flags included | ||
| 143 | - [ ] Models can determine what's needed before calling | ||
| 144 | - [ ] UI narrative includes this metadata | ||
| 145 | |||
| 146 | ### 6. Screen Navigation Error Handling (COMPLETED) | ||
| 147 | **Problem**: Silent failures in deep screens | ||
| 148 | **Impact**: ANY model cannot reach important business functions | ||
| 149 | **Priority**: Medium | ||
| 150 | |||
| 151 | **Files modified**: | ||
| 152 | - ✅ `service/McpServices.xml` (lines 952-962) - Specific error messages | ||
| 153 | |||
| 154 | **Changes made**: | ||
| 155 | - Added specific error messages when navigation fails | ||
| 156 | - Log which segment failed in path | ||
| 157 | - Log available subscreens on failure | ||
| 158 | - Prevent silent failures | ||
| 159 | |||
| 160 | **Validation required**: | ||
| 161 | - [ ] Navigation failures show which segment failed | ||
| 162 | - [ ] Error lists available subscreens | ||
| 163 | - [ ] Models can navigate to correct screen | ||
| 164 | - [ ] No silent failures | ||
| 165 | |||
| 166 | --- | ||
| 167 | |||
| 168 | ## ✅ Implementation Status Summary | ||
| 169 | |||
| 170 | ### Phase 1: Documentation ✅ | ||
| 171 | - [x] AGENTS.md created | ||
| 172 | - [x] Wiki documentation updated to use `/` delimiter | ||
| 173 | - [x] All path examples updated | ||
| 174 | |||
| 175 | ### Phase 2: Near-Term Fixes ✅ | ||
| 176 | - [x] Path delimiter changed to `/` (backward compatible) | ||
| 177 | - [x] Screen path resolution fixed with Admin vs Root distinction | ||
| 178 | - [x] Dynamic service name resolution implemented | ||
| 179 | - [x] Parameter validation added (collects all errors) | ||
| 180 | - [x] Transition metadata enhanced with parameter details | ||
| 181 | - [x] Screen navigation error handling improved | ||
| 182 | |||
| 183 | ### Phase 3: Validation & Testing (PENDING) | ||
| 184 | - [ ] Server restart required to load changes | ||
| 185 | - [ ] Screen rendering tests run manually | ||
| 186 | - [ ] Transition execution tests run manually | ||
| 187 | - [ ] Path delimiter tests run manually | ||
| 188 | - [ ] Model-agnostic tests run (if models available) | ||
| 189 | |||
| 190 | --- | ||
| 191 | |||
| 192 | ## ✅ Validation: Generic Model Access | ||
| 193 | |||
| 194 | ### Screen Rendering Tests (Requires server restart) | ||
| 195 | - [ ] Root screens (PopCommerce, SimpleScreens) render with uiNarrative | ||
| 196 | - [ ] Admin subscreens (Catalog, Order, Customer) accessible | ||
| 197 | - [ ] FindProduct screen renders with search form | ||
| 198 | - [ ] EditPrices screen renders with product data | ||
| 199 | - [ ] FindOrder screen renders with order data | ||
| 200 | - [ ] All screens have semantic state with forms/lists | ||
| 201 | - [ ] UI narratives are clear and actionable | ||
| 202 | |||
| 203 | ### Transition Execution Tests (Requires server restart) | ||
| 204 | - [ ] Create actions work for all entity types | ||
| 205 | - [ ] Update actions work for all entity types | ||
| 206 | - [ ] Delete actions work where applicable | ||
| 207 | - [ ] Form submissions process parameters correctly | ||
| 208 | - [ ] Parameter validation catches missing fields | ||
| 209 | - [ ] Invalid parameters return helpful errors | ||
| 210 | |||
| 211 | ### Path Delimiter Tests (Requires server restart) | ||
| 212 | - [ ] `/PopCommerce/PopCommerceAdmin/Catalog/Product` works | ||
| 213 | - [ ] `PopCommerce.PopCommerceAdmin.Catalog.Product` still works (backward compat) | ||
| 214 | - [ ] Navigation links use `/` in output | ||
| 215 | - [ ] Error messages reference paths with `/` | ||
| 216 | - [ ] Documentation updated to use `/` | ||
| 217 | |||
| 218 | ### Model Agnostic Tests (If possible) | ||
| 219 | - [ ] Screens work with any model (test with 2-3 if available) | ||
| 220 | - [ ] UI narrative provides sufficient guidance for autonomous action | ||
| 221 | - [ ] Errors are clear regardless of model choice | ||
| 222 | - [ ] No model-specific code or assumptions | ||
| 223 | |||
| 224 | ### End-to-End Business Tasks (Requires server restart) | ||
| 225 | **Test with multiple models to ensure generic access:** | ||
| 226 | - [ ] Product search (any query pattern) | ||
| 227 | - [ ] Price update (any product, any price) | ||
| 228 | - [ ] Customer lookup (any customer identifier) | ||
| 229 | - [ ] Order creation (any customer, any product) | ||
| 230 | - [ ] Order status check (any order ID) | ||
| 231 | - [ ] Multi-step workflows (browse → execute → verify) | ||
| 232 | |||
| 233 | --- | ||
| 234 | |||
| 235 | ## 📊 Success Metrics | ||
| 236 | |||
| 237 | ### Narrative Quality | ||
| 238 | - **Coverage**: 100% of screens should have uiNarrative | ||
| 239 | - **Clarity**: Models can understand current state from 50-80 word descriptions | ||
| 240 | - **Actionability**: Models have exact tool invocation examples for all actions | ||
| 241 | - **Navigation**: Models can navigate hierarchy independently | ||
| 242 | |||
| 243 | ### Functional Coverage | ||
| 244 | - **Screen Access**: All documented screens should render successfully | ||
| 245 | - **Transition Types**: All action patterns (create, update, delete, submit) should work | ||
| 246 | - **Entity Coverage**: Should work across Product, Order, Customer, Inventory entities | ||
| 247 | - **Error Handling**: Clear, actionable error messages for all failure modes | ||
| 248 | |||
| 249 | ### Model Agnosticism | ||
| 250 | - **Provider Independence**: Works with OpenAI, Anthropic, local models | ||
| 251 | - **Size Independence**: Effective for 7B models and 70B models | ||
| 252 | - **Input Flexibility**: Handles various natural language phrasings | ||
| 253 | - **Output Consistency**: Reliable responses regardless of model choice | ||
| 254 | |||
| 255 | --- | ||
| 256 | |||
| 257 | ## 🧪 Use Cases (Not Exhaustive) | ||
| 258 | |||
| 259 | ### Human-in-the-Loop | ||
| 260 | - User: "Help me find products" | ||
| 261 | - Model: Screens for browsing, narrows to products, presents options | ||
| 262 | - User: Selects product, asks for price change | ||
| 263 | - Model: Executes price update, confirms | ||
| 264 | - User: Reviews and approves | ||
| 265 | |||
| 266 | ### External AI Integration | ||
| 267 | - External system: "Create order for customer CUST-001: 5 units of PROD-002" | ||
| 268 | - HTTP API to Moqui MCP | ||
| 269 | - MCP: Executes order creation | ||
| 270 | - Returns: Order ID and confirmation | ||
| 271 | - External system: Confirms and updates records | ||
| 272 | |||
| 273 | ### Manual Model Testing | ||
| 274 | - Developer: Runs model through MCP interface | ||
| 275 | - Model: Navigates screens, performs tasks | ||
| 276 | - Developer: Observes behavior, validates output | ||
| 277 | - Developer: Adjusts UI narrative or transition logic based on model struggles | ||
| 278 | |||
| 279 | --- | ||
| 280 | |||
| 281 | ## 🚀 Future Enhancements | ||
| 282 | |||
| 283 | Beyond core narrative screens: | ||
| 284 | - Multi-agent coordination via notifications | ||
| 285 | - Context retention across sessions | ||
| 286 | - Proactive suggestions | ||
| 287 | - Advanced workflow orchestration | ||
| 288 | - Agent that monitors notifications and executes tasks autonomously |
| ... | @@ -67,21 +67,21 @@ For ${focus?.capitalize() ?: 'General'} operations: | ... | @@ -67,21 +67,21 @@ For ${focus?.capitalize() ?: 'General'} operations: |
| 67 | 67 | ||
| 68 | ${focus == 'catalog' ? ''' | 68 | ${focus == 'catalog' ? ''' |
| 69 | ## Catalog Operations | 69 | ## Catalog Operations |
| 70 | - **FindProduct**: Use `PopCommerce.PopCommerceAdmin.Catalog.Product.FindProduct` for product catalog | 70 | - **FindProduct**: Use `/PopCommerce/PopCommerceAdmin.Catalog.Product.FindProduct` for product catalog |
| 71 | - **FindFeature**: Use `PopCommerce.PopCommerceAdmin.Catalog.Feature.FindFeature` to search by features like color or size | 71 | - **FindFeature**: Use `/PopCommerce/PopCommerceAdmin.Catalog.Feature.FindFeature` to search by features like color or size |
| 72 | - **EditPrices**: Use `PopCommerce.PopCommerceAdmin.Catalog.Product.EditPrices` to check prices | 72 | - **EditPrices**: Use `/PopCommerce/PopCommerceAdmin.Catalog.Product.EditPrices` to check prices |
| 73 | ''' : ''} | 73 | ''' : ''} |
| 74 | 74 | ||
| 75 | ${focus == 'orders' ? ''' | 75 | ${focus == 'orders' ? ''' |
| 76 | ## Order Operations | 76 | ## Order Operations |
| 77 | - **FindOrder**: Use `PopCommerce.PopCommerceAdmin.Order.FindOrder` for order status and lookup | 77 | - **FindOrder**: Use `/PopCommerce/PopCommerceAdmin.Order.FindOrder` for order status and lookup |
| 78 | - **QuickSearch**: Use `PopCommerce.PopCommerceAdmin.QuickSearch` for general order searches | 78 | - **QuickSearch**: Use `/PopCommerce/PopCommerceAdmin.QuickSearch` for general order searches |
| 79 | ''' : ''} | 79 | ''' : ''} |
| 80 | 80 | ||
| 81 | ${focus == 'customers' ? ''' | 81 | ${focus == 'customers' ? ''' |
| 82 | ## Customer Operations | 82 | ## Customer Operations |
| 83 | - **CustomerRoot**: Use `PopCommerce.PopCommerceRoot.Customer` for customer management | 83 | - **CustomerRoot**: Use `/PopCommerce/PopCommerceRoot.Customer` for customer management |
| 84 | - **QuickSearch**: Use `PopCommerce.PopCommerceAdmin.QuickSearch` for customer lookup | 84 | - **QuickSearch**: Use `/PopCommerce/PopCommerceAdmin.QuickSearch` for customer lookup |
| 85 | ''' : ''} | 85 | ''' : ''} |
| 86 | 86 | ||
| 87 | ${detailLevel == 'advanced' ? ''' | 87 | ${detailLevel == 'advanced' ? ''' | ... | ... |
| ... | @@ -45,23 +45,23 @@ Use the following discovery tools to explore available functionality: | ... | @@ -45,23 +45,23 @@ Use the following discovery tools to explore available functionality: |
| 45 | ## Common Screen Paths | 45 | ## Common Screen Paths |
| 46 | 46 | ||
| 47 | ### Catalog Operations | 47 | ### Catalog Operations |
| 48 | - `PopCommerce.PopCommerceAdmin.Catalog.Product.FindProduct`: Search and browse products | 48 | - `/PopCommerce/PopCommerceAdmin.Catalog.Product.FindProduct`: Search and browse products |
| 49 | - `PopCommerce.PopCommerceAdmin.Catalog.Feature.FindFeature`: Search by features like color or size | 49 | - `/PopCommerce/PopCommerceAdmin.Catalog.Feature.FindFeature`: Search by features like color or size |
| 50 | - `PopCommerce.PopCommerceAdmin.Catalog.Product.EditPrices`: View and update product prices | 50 | - `/PopCommerce/PopCommerceAdmin.Catalog.Product.EditPrices`: View and update product prices |
| 51 | 51 | ||
| 52 | ### Order Management | 52 | ### Order Management |
| 53 | - `PopCommerce.PopCommerceAdmin.Order.FindOrder`: Lookup order status and details | 53 | - `/PopCommerce/PopCommerceAdmin.Order.FindOrder`: Lookup order status and details |
| 54 | - `PopCommerce.PopCommerceAdmin.QuickSearch`: General order and customer search | 54 | - `/PopCommerce/PopCommerceAdmin.QuickSearch`: General order and customer search |
| 55 | 55 | ||
| 56 | ### Customer Management | 56 | ### Customer Management |
| 57 | - `PopCommerce.PopCommerceRoot.Customer`: Manage customer accounts | 57 | - `/PopCommerce/PopCommerceRoot.Customer`: Manage customer accounts |
| 58 | - `PopCommerce.PopCommerceAdmin.QuickSearch`: Customer lookup | 58 | - `/PopCommerce/PopCommerceAdmin.QuickSearch`: Customer lookup |
| 59 | 59 | ||
| 60 | ## Tips for LLM Clients | 60 | ## Tips for LLM Clients |
| 61 | 61 | ||
| 62 | - All screens support parameterized queries for filtering results | 62 | - All screens support parameterized queries for filtering results |
| 63 | - Use `moqui_render_screen` with screen path to execute screens | 63 | - Use `moqui_render_screen` with screen path to execute screens |
| 64 | - Screen paths use dot notation (e.g., `PopCommerce.Catalog.Product`) | 64 | - Screen paths use dot notation (e.g., `/PopCommerce/Catalog.Product`) |
| 65 | - Check `moqui_get_screen_details` for required parameters before rendering | 65 | - Check `moqui_get_screen_details` for required parameters before rendering |
| 66 | - Use `renderMode: "mcp"` for structured JSON output or `"text"` for human-readable format]]></fileData> | 66 | - Use `renderMode: "mcp"` for structured JSON output or `"text"` for human-readable format]]></fileData> |
| 67 | </moqui.resource.DbResourceFile> | 67 | </moqui.resource.DbResourceFile> |
| ... | @@ -107,23 +107,23 @@ Use the following discovery tools to explore available functionality: | ... | @@ -107,23 +107,23 @@ Use the following discovery tools to explore available functionality: |
| 107 | ## Common Screen Paths | 107 | ## Common Screen Paths |
| 108 | 108 | ||
| 109 | ### Catalog Operations | 109 | ### Catalog Operations |
| 110 | - `PopCommerce.PopCommerceAdmin.Catalog.Product.FindProduct`: Search and browse products | 110 | - `/PopCommerce/PopCommerceAdmin.Catalog.Product.FindProduct`: Search and browse products |
| 111 | - `PopCommerce.PopCommerceAdmin.Catalog.Feature.FindFeature`: Search by features like color or size | 111 | - `/PopCommerce/PopCommerceAdmin.Catalog.Feature.FindFeature`: Search by features like color or size |
| 112 | - `PopCommerce.PopCommerceAdmin.Catalog.Product.EditPrices`: View and update product prices | 112 | - `/PopCommerce/PopCommerceAdmin.Catalog.Product.EditPrices`: View and update product prices |
| 113 | 113 | ||
| 114 | ### Order Management | 114 | ### Order Management |
| 115 | - `PopCommerce.PopCommerceAdmin.Order.FindOrder`: Lookup order status and details | 115 | - `/PopCommerce/PopCommerceAdmin.Order.FindOrder`: Lookup order status and details |
| 116 | - `PopCommerce.PopCommerceAdmin.QuickSearch`: General order and customer search | 116 | - `/PopCommerce/PopCommerceAdmin.QuickSearch`: General order and customer search |
| 117 | 117 | ||
| 118 | ### Customer Management | 118 | ### Customer Management |
| 119 | - `PopCommerce.PopCommerceRoot.Customer`: Manage customer accounts | 119 | - `/PopCommerce/PopCommerceRoot.Customer`: Manage customer accounts |
| 120 | - `PopCommerce.PopCommerceAdmin.QuickSearch`: Customer lookup | 120 | - `/PopCommerce/PopCommerceAdmin.QuickSearch`: Customer lookup |
| 121 | 121 | ||
| 122 | ## Tips for LLM Clients | 122 | ## Tips for LLM Clients |
| 123 | 123 | ||
| 124 | - All screens support parameterized queries for filtering results | 124 | - All screens support parameterized queries for filtering results |
| 125 | - Use `moqui_render_screen` with screen path to execute screens | 125 | - Use `moqui_render_screen` with screen path to execute screens |
| 126 | - Screen paths use dot notation (e.g., `PopCommerce.Catalog.Product`) | 126 | - Screen paths use dot notation (e.g., `/PopCommerce/Catalog.Product`) |
| 127 | - Check `moqui_get_screen_details` for required parameters before rendering | 127 | - Check `moqui_get_screen_details` for required parameters before rendering |
| 128 | - Use `renderMode: "mcp"` for structured JSON output or `"text"` for human-readable format]]></fileData> | 128 | - Use `renderMode: "mcp"` for structured JSON output or `"text"` for human-readable format]]></fileData> |
| 129 | </moqui.resource.DbResourceFile> | 129 | </moqui.resource.DbResourceFile> |
| ... | @@ -132,7 +132,7 @@ Use the following discovery tools to explore available functionality: | ... | @@ -132,7 +132,7 @@ Use the following discovery tools to explore available functionality: |
| 132 | <moqui.resource.DbResource | 132 | <moqui.resource.DbResource |
| 133 | resourceId="WIKI_MCP_DOCS_POPCOMM_ROOT" | 133 | resourceId="WIKI_MCP_DOCS_POPCOMM_ROOT" |
| 134 | parentResourceId="WIKI_MCP_SCREEN_DOCS" | 134 | parentResourceId="WIKI_MCP_SCREEN_DOCS" |
| 135 | filename="PopCommerce.PopCommerceRoot.md" | 135 | filename="PopCommerce/PopCommerceRoot.md" |
| 136 | isFile="Y"/> | 136 | isFile="Y"/> |
| 137 | <moqui.resource.DbResourceFile | 137 | <moqui.resource.DbResourceFile |
| 138 | resourceId="WIKI_MCP_DOCS_POPCOMM_ROOT" | 138 | resourceId="WIKI_MCP_DOCS_POPCOMM_ROOT" |
| ... | @@ -158,7 +158,7 @@ Use browse tools to explore the full catalog of PopCommerce screens starting fro | ... | @@ -158,7 +158,7 @@ Use browse tools to explore the full catalog of PopCommerce screens starting fro |
| 158 | <moqui.resource.wiki.WikiPage | 158 | <moqui.resource.wiki.WikiPage |
| 159 | wikiPageId="MCP_SCREEN_DOCS/PopCommerceRoot" | 159 | wikiPageId="MCP_SCREEN_DOCS/PopCommerceRoot" |
| 160 | wikiSpaceId="MCP_SCREEN_DOCS" | 160 | wikiSpaceId="MCP_SCREEN_DOCS" |
| 161 | pagePath="PopCommerce.PopCommerceRoot" | 161 | pagePath="PopCommerce/PopCommerceRoot" |
| 162 | publishedVersionName="v1" | 162 | publishedVersionName="v1" |
| 163 | restrictView="N"> | 163 | restrictView="N"> |
| 164 | </moqui.resource.wiki.WikiPage> | 164 | </moqui.resource.wiki.WikiPage> | ... | ... |
| ... | @@ -7,7 +7,9 @@ | ... | @@ -7,7 +7,9 @@ |
| 7 | 7 | ||
| 8 | <#macro @element></#macro> | 8 | <#macro @element></#macro> |
| 9 | 9 | ||
| 10 | <#macro screen><#recurse></#macro> | 10 | <#macro screen> |
| 11 | <#recurse> | ||
| 12 | </#macro> | ||
| 11 | 13 | ||
| 12 | <#macro widgets> | 14 | <#macro widgets> |
| 13 | <#recurse> | 15 | <#recurse> |
| ... | @@ -48,6 +50,7 @@ | ... | @@ -48,6 +50,7 @@ |
| 48 | 50 | ||
| 49 | <#macro "container-dialog"> | 51 | <#macro "container-dialog"> |
| 50 | [Button: ${ec.resource.expand(.node["@button-text"], "")}] | 52 | [Button: ${ec.resource.expand(.node["@button-text"], "")}] |
| 53 | <#recurse> | ||
| 51 | </#macro> | 54 | </#macro> |
| 52 | 55 | ||
| 53 | <#-- ================== Standalone Fields ==================== --> | 56 | <#-- ================== Standalone Fields ==================== --> |
| ... | @@ -66,18 +69,18 @@ | ... | @@ -66,18 +69,18 @@ |
| 66 | <#assign linkText = sri.getFieldValueString(.node?parent?parent)> | 69 | <#assign linkText = sri.getFieldValueString(.node?parent?parent)> |
| 67 | </#if> | 70 | </#if> |
| 68 | 71 | ||
| 69 | <#-- Convert path to dot notation for moqui_render_screen --> | 72 | <#-- Convert path to slash notation for moqui_render_screen (matches browser URLs) --> |
| 70 | <#assign fullPath = urlInstance.sui.fullPathNameList![]> | 73 | <#assign fullPath = urlInstance.sui.fullPathNameList![]> |
| 71 | <#assign dotPath = ""> | 74 | <#assign slashPath = ""> |
| 72 | <#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list> | 75 | <#list fullPath as pathPart><#assign slashPath = slashPath + (slashPath?has_content)?then("/", "") + pathPart></#list> |
| 73 | 76 | ||
| 74 | <#assign paramStr = urlInstance.getParameterString()> | 77 | <#assign paramStr = urlInstance.getParameterString()> |
| 75 | <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> | 78 | <#if paramStr?has_content><#assign slashPath = slashPath + "?" + paramStr></#if> |
| 76 | 79 | ||
| 77 | [${linkText}](${dotPath})<#t> | 80 | [${linkText}](${slashPath})<#t> |
| 78 | <#if mcpSemanticData??> | 81 | <#if mcpSemanticData??> |
| 79 | <#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if> | 82 | <#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if> |
| 80 | <#assign linkInfo = {"text": linkText, "path": dotPath, "type": "navigation"}> | 83 | <#assign linkInfo = {"text": linkText, "path": slashPath, "type": "navigation"}> |
| 81 | <#assign dummy = mcpSemanticData.links.add(linkInfo)> | 84 | <#assign dummy = mcpSemanticData.links.add(linkInfo)> |
| 82 | </#if> | 85 | </#if> |
| 83 | </#if> | 86 | </#if> |
| ... | @@ -98,17 +101,16 @@ | ... | @@ -98,17 +101,16 @@ |
| 98 | <#-- ======================= Form ========================= --> | 101 | <#-- ======================= Form ========================= --> |
| 99 | <#macro "form-single"> | 102 | <#macro "form-single"> |
| 100 | <#assign formNode = sri.getFormNode(.node["@name"])> | 103 | <#assign formNode = sri.getFormNode(.node["@name"])> |
| 101 | <#assign mapName = formNode["@map"]!"fieldValues"> | 104 | <#assign mapName = (formNode["@map"]!"fieldValues")> |
| 102 | <#assign formMap = ec.resource.expression(mapName, "")!> | 105 | <#assign formMap = ec.resource.expression(mapName, "")!> |
| 103 | 106 | ||
| 104 | <#if mcpSemanticData??> | 107 | <#if mcpSemanticData??> |
| 105 | <#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})</#if> | 108 | <#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})></#if> |
| 106 | 109 | ||
| 107 | <#assign formMeta = {}> | 110 | <#assign formMeta = {"name": (.node["@name"]!""), "map": mapName}> |
| 108 | <#assign formMeta = formMeta + {"name": .node["@name"]!"", "map": mapName}> | ||
| 109 | <#assign fieldMetaList = []> | 111 | <#assign fieldMetaList = []> |
| 110 | 112 | ||
| 111 | <#assign dummy = mcpSemanticData.formMeta.put(.node["@name"], formMeta)> | 113 | <#assign dummy = mcpSemanticData.formMetadata.put(.node["@name"], formMeta)> |
| 112 | </#if> | 114 | </#if> |
| 113 | 115 | ||
| 114 | <#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if> | 116 | <#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if> |
| ... | @@ -121,8 +123,7 @@ | ... | @@ -121,8 +123,7 @@ |
| 121 | <#assign title><@fieldTitle fieldSubNode/></#assign> | 123 | <#assign title><@fieldTitle fieldSubNode/></#assign> |
| 122 | 124 | ||
| 123 | <#if mcpSemanticData??> | 125 | <#if mcpSemanticData??> |
| 124 | <#assign fieldMeta = {}> | 126 | <#assign fieldMeta = {"name": (fieldNode["@name"]!""), "title": (title!), "required": (fieldNode["@required"]! == "true")}> |
| 125 | <#assign fieldMeta = fieldMeta + {"name": fieldNode["@name"]!"", "title": title!"", "required": (fieldNode["@required"]! == "true")}> | ||
| 126 | 127 | ||
| 127 | <#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if> | 128 | <#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if> |
| 128 | <#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if> | 129 | <#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if> |
| ... | @@ -138,7 +139,7 @@ | ... | @@ -138,7 +139,7 @@ |
| 138 | </#list> | 139 | </#list> |
| 139 | 140 | ||
| 140 | <#if mcpSemanticData?? && fieldMetaList?has_content> | 141 | <#if mcpSemanticData?? && fieldMetaList?has_content> |
| 141 | <#assign dummy = mcpSemanticData.formMeta[.node["@name"]!].put("fields", fieldMetaList)> | 142 | <#assign dummy = mcpSemanticData.formMetadata[.node["@name"]!].put("fields", fieldMetaList)> |
| 142 | </#if> | 143 | </#if> |
| 143 | 144 | ||
| 144 | <#t>${sri.popContext()} | 145 | <#t>${sri.popContext()} |
| ... | @@ -154,12 +155,9 @@ | ... | @@ -154,12 +155,9 @@ |
| 154 | 155 | ||
| 155 | <#if mcpSemanticData?? && listObject?has_content> | 156 | <#if mcpSemanticData?? && listObject?has_content> |
| 156 | <#assign truncatedList = listObject> | 157 | <#assign truncatedList = listObject> |
| 157 | <#if listObject?size > 50> | ||
| 158 | <#assign truncatedList = listObject?take(50)> | ||
| 159 | </#if> | ||
| 160 | <#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)> | 158 | <#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)> |
| 161 | 159 | ||
| 162 | <#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})</#if> | 160 | <#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})></#if> |
| 163 | 161 | ||
| 164 | <#assign columnNames = []> | 162 | <#assign columnNames = []> |
| 165 | <#list formListColumnList as columnFieldList> | 163 | <#list formListColumnList as columnFieldList> |
| ... | @@ -167,11 +165,11 @@ | ... | @@ -167,11 +165,11 @@ |
| 167 | <#assign dummy = columnNames.add(fieldNode["@name"]!"")> | 165 | <#assign dummy = columnNames.add(fieldNode["@name"]!"")> |
| 168 | </#list> | 166 | </#list> |
| 169 | 167 | ||
| 170 | <#assign dummy = mcpSemanticData.listMeta.put(.node["@name"]!"", { | 168 | <#assign dummy = mcpSemanticData.listMetadata.put(.node["@name"]!"", { |
| 171 | "name": .node["@name"]!"", | 169 | "name": .node["@name"]!"", |
| 172 | "totalItems": totalItems, | 170 | "totalItems": totalItems, |
| 173 | "displayedItems": truncatedList?size, | 171 | "displayedItems": (totalItems > 50)?then(50, totalItems), |
| 174 | "truncated": (listObject?size > 50), | 172 | "truncated": (totalItems > 50), |
| 175 | "columns": columnNames | 173 | "columns": columnNames |
| 176 | })> | 174 | })> |
| 177 | </#if> | 175 | </#if> |
| ... | @@ -185,7 +183,8 @@ | ... | @@ -185,7 +183,8 @@ |
| 185 | | | 183 | | |
| 186 | <#list formListColumnList as columnFieldList>| --- </#list>| | 184 | <#list formListColumnList as columnFieldList>| --- </#list>| |
| 187 | <#-- Data Rows --> | 185 | <#-- Data Rows --> |
| 188 | <#list (truncatedList?? && truncatedList?size > 0)!listObject as listEntry> | 186 | <#list listObject as listEntry> |
| 187 | <#if (listEntry_index >= 50)><#break></#if> | ||
| 189 | <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)} | 188 | <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)} |
| 190 | <#list formListColumnList as columnFieldList> | 189 | <#list formListColumnList as columnFieldList> |
| 191 | <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t> | 190 | <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t> | ... | ... |
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!-- | ||
| 3 | This software is in the public domain under CC0 1.0 Universal plus a | ||
| 4 | Grant of Patent License. | ||
| 5 | |||
| 6 | To the extent possible under law, author(s) have dedicated all | ||
| 7 | copyright and related and neighboring rights to this software to the | ||
| 8 | public domain worldwide. This software is distributed without any | ||
| 9 | warranty. | ||
| 10 | --> | ||
| 11 | <services xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-3.xsd"> | ||
| 12 | |||
| 13 | <service verb="mcp" noun="ExecuteScreenAction" authenticate="true" allow-remote="true" transaction-timeout="60"> | ||
| 14 | <description>Execute a screen action (transition or CRUD convention) with parameters.</description> | ||
| 15 | <in-parameters> | ||
| 16 | <parameter name="path" required="true"/> | ||
| 17 | <parameter name="action" required="true"/> | ||
| 18 | <parameter name="parameters" type="Map"/> | ||
| 19 | </in-parameters> | ||
| 20 | <out-parameters> | ||
| 21 | <parameter name="result" type="Map"/> | ||
| 22 | </out-parameters> | ||
| 23 | <actions> | ||
| 24 | <script><![CDATA[ | ||
| 25 | import org.moqui.context.ExecutionContext | ||
| 26 | |||
| 27 | ExecutionContext ec = context.ec | ||
| 28 | def actionResult = null | ||
| 29 | def actionError = null | ||
| 30 | def actionParams = parameters ?: [:] | ||
| 31 | |||
| 32 | try { | ||
| 33 | // First, resolve the screen path | ||
| 34 | def resolveResult = ec.service.sync().name("McpServices.mcp#ResolveScreenPath") | ||
| 35 | .parameter("path", path).call() | ||
| 36 | |||
| 37 | def screenPath = resolveResult.screenPath | ||
| 38 | def screenDef = resolveResult.screenDef | ||
| 39 | |||
| 40 | if (!screenDef) { | ||
| 41 | actionError = "Could not resolve screen for path '${path}'" | ||
| 42 | result = [actionResult: actionResult, actionError: actionError] | ||
| 43 | return | ||
| 44 | } | ||
| 45 | |||
| 46 | if (action == "submit") { | ||
| 47 | // Special handling for form submit - just acknowledge receipt | ||
| 48 | actionResult = [ | ||
| 49 | action: "submit", | ||
| 50 | status: "success", | ||
| 51 | message: "Form parameters submitted", | ||
| 52 | parametersProcessed: actionParams.keySet() | ||
| 53 | ] | ||
| 54 | } else { | ||
| 55 | // Check if action matches CRUD convention for ProductPrice | ||
| 56 | def actionPrefix = action?.take(6) | ||
| 57 | if (actionPrefix && actionPrefix in ['update', 'create', 'delete']) { | ||
| 58 | // Convention: updateProductPrice -> update#mantle.product.ProductPrice | ||
| 59 | def serviceName = "${actionPrefix}#mantle.product.ProductPrice" | ||
| 60 | ec.logger.info("ExecuteScreenAction: Calling service by convention: ${serviceName}") | ||
| 61 | |||
| 62 | try { | ||
| 63 | def serviceCallResult = ec.service.sync().name(serviceName).parameters(actionParams).call() | ||
| 64 | actionResult = [ | ||
| 65 | action: action, | ||
| 66 | status: "executed", | ||
| 67 | message: "Executed service ${serviceName}", | ||
| 68 | result: serviceCallResult | ||
| 69 | ] | ||
| 70 | } catch (Exception e) { | ||
| 71 | actionError = "Service call failed: ${e.message}" | ||
| 72 | } | ||
| 73 | } else { | ||
| 74 | // Look for matching transition on screen | ||
| 75 | def foundTransition = null | ||
| 76 | def allTransitions = screenDef.getAllTransitions() | ||
| 77 | if (allTransitions) { | ||
| 78 | for (def transition : allTransitions) { | ||
| 79 | if (transition.getName() == action) { | ||
| 80 | foundTransition = transition | ||
| 81 | break | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | if (foundTransition) { | ||
| 87 | // Try to find and execute the transition's service | ||
| 88 | def serviceName = null | ||
| 89 | if (foundTransition.xmlTransition) { | ||
| 90 | def serviceCallNode = foundTransition.xmlTransition.first("service-call") | ||
| 91 | if (serviceCallNode) serviceName = serviceCallNode.attribute("name") | ||
| 92 | } | ||
| 93 | |||
| 94 | if (serviceName) { | ||
| 95 | ec.logger.info("ExecuteScreenAction: Executing transition '${action}' service: ${serviceName}") | ||
| 96 | try { | ||
| 97 | def serviceCallResult = ec.service.sync().name(serviceName).parameters(actionParams).call() | ||
| 98 | actionResult = [ | ||
| 99 | action: action, | ||
| 100 | status: "executed", | ||
| 101 | message: "Executed service ${serviceName}", | ||
| 102 | result: serviceCallResult | ||
| 103 | ] | ||
| 104 | } catch (Exception e) { | ||
| 105 | actionError = "Service call failed: ${e.message}" | ||
| 106 | } | ||
| 107 | } else { | ||
| 108 | actionResult = [ | ||
| 109 | action: action, | ||
| 110 | status: "success", | ||
| 111 | message: "Transition '${action}' ready (no direct service)" | ||
| 112 | ] | ||
| 113 | } | ||
| 114 | } else { | ||
| 115 | actionError = "Transition '${action}' not found on screen" | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } | ||
| 119 | } catch (Exception e) { | ||
| 120 | actionError = "Action execution failed: ${e.message}" | ||
| 121 | ec.logger.warn("ExecuteScreenAction error: ${e.message}") | ||
| 122 | } | ||
| 123 | |||
| 124 | result = [actionResult: actionResult, actionError: actionError] | ||
| 125 | ]]></script> | ||
| 126 | </actions> | ||
| 127 | </service> | ||
| 128 | |||
| 129 | </services> |
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!-- | ||
| 3 | This software is in the public domain under CC0 1.0 Universal plus a | ||
| 4 | Grant of Patent License. | ||
| 5 | |||
| 6 | To the extent possible under law, author(s) have dedicated all | ||
| 7 | copyright and related and neighboring rights to this software to the | ||
| 8 | public domain worldwide. This software is distributed without any | ||
| 9 | warranty. | ||
| 10 | --> | ||
| 11 | <services xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-3.xsd"> | ||
| 12 | |||
| 13 | <service verb="mcp" noun="RenderScreenNarrative" authenticate="true" allow-remote="true" transaction-timeout="120"> | ||
| 14 | <description>Render a screen with semantic state extraction and UI narrative generation.</description> | ||
| 15 | <in-parameters> | ||
| 16 | <parameter name="path" required="true"/> | ||
| 17 | <parameter name="parameters" type="Map"/> | ||
| 18 | <parameter name="renderMode" default="mcp"/> | ||
| 19 | <parameter name="sessionId"/> | ||
| 20 | <parameter name="terse" type="Boolean" default="false"/> | ||
| 21 | </in-parameters> | ||
| 22 | <out-parameters> | ||
| 23 | <parameter name="result" type="Map"/> | ||
| 24 | </out-parameters> | ||
| 25 | <actions> | ||
| 26 | <script><![CDATA[ | ||
| 27 | import org.moqui.context.ExecutionContext | ||
| 28 | |||
| 29 | ExecutionContext ec = context.ec | ||
| 30 | def renderedContent = null | ||
| 31 | def semanticState = null | ||
| 32 | def uiNarrative = null | ||
| 33 | |||
| 34 | // Resolve screen path | ||
| 35 | def resolveResult = ec.service.sync().name("McpServices.mcp#ResolveScreenPath") | ||
| 36 | .parameter("path", path).call() | ||
| 37 | |||
| 38 | def screenPath = resolveResult.screenPath | ||
| 39 | def subscreenName = resolveResult.subscreenName | ||
| 40 | |||
| 41 | if (!screenPath) { | ||
| 42 | result = [renderedContent: null, semanticState: null, uiNarrative: null] | ||
| 43 | return | ||
| 44 | } | ||
| 45 | |||
| 46 | // Build render parameters | ||
| 47 | def screenCallParams = [ | ||
| 48 | path: screenPath, | ||
| 49 | parameters: parameters ?: [:], | ||
| 50 | renderMode: renderMode ?: "mcp", | ||
| 51 | sessionId: sessionId, | ||
| 52 | terse: terse == true | ||
| 53 | ] | ||
| 54 | if (subscreenName) screenCallParams.subscreenName = subscreenName | ||
| 55 | |||
| 56 | // Render screen using ScreenAsMcpTool | ||
| 57 | try { | ||
| 58 | def serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") | ||
| 59 | .parameters(screenCallParams).call() | ||
| 60 | |||
| 61 | // Extract semantic state from rendered result | ||
| 62 | if (serviceResult) { | ||
| 63 | def resultObj = null | ||
| 64 | if (serviceResult.containsKey('content') && serviceResult.content && serviceResult.content.size() > 0) { | ||
| 65 | def rawText = serviceResult.content[0].text | ||
| 66 | if (rawText && rawText.startsWith("{")) { | ||
| 67 | try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {} | ||
| 68 | } | ||
| 69 | renderedContent = rawText | ||
| 70 | } else if (serviceResult.containsKey('result') && serviceResult.result && serviceResult.result.content && serviceResult.result.content.size() > 0) { | ||
| 71 | def rawText = serviceResult.result.content[0].text | ||
| 72 | if (rawText && rawText.startsWith("{")) { | ||
| 73 | try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {} | ||
| 74 | } | ||
| 75 | renderedContent = rawText | ||
| 76 | } | ||
| 77 | |||
| 78 | // Generate UI narrative if we have semantic state | ||
| 79 | if (resultObj && resultObj.semanticState) { | ||
| 80 | semanticState = resultObj.semanticState | ||
| 81 | |||
| 82 | try { | ||
| 83 | def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder() | ||
| 84 | def screenDefForNarrative = ec.screen.getScreenDefinition(screenPath) | ||
| 85 | |||
| 86 | uiNarrative = narrativeBuilder.buildNarrative( | ||
| 87 | screenDefForNarrative, | ||
| 88 | semanticState, | ||
| 89 | path, | ||
| 90 | terse == true | ||
| 91 | ) | ||
| 92 | ec.logger.info("RenderScreenNarrative: Generated UI narrative for ${path}") | ||
| 93 | } catch (Exception e) { | ||
| 94 | ec.logger.warn("RenderScreenNarrative: Failed to generate UI narrative: ${e.message}") | ||
| 95 | } | ||
| 96 | |||
| 97 | // Truncate content if we have UI narrative to save tokens | ||
| 98 | if (renderedContent && renderedContent.length() > 500) { | ||
| 99 | renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)" | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } catch (Exception e) { | ||
| 104 | ec.logger.warn("RenderScreenNarrative: Error rendering screen ${path}: ${e.message}") | ||
| 105 | renderedContent = "RENDER_ERROR: ${e.message}" | ||
| 106 | } | ||
| 107 | |||
| 108 | result = [ | ||
| 109 | renderedContent: renderedContent, | ||
| 110 | semanticState: semanticState, | ||
| 111 | uiNarrative: uiNarrative | ||
| 112 | ] | ||
| 113 | ]]></script> | ||
| 114 | </actions> | ||
| 115 | </service> | ||
| 116 | |||
| 117 | </services> |
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!-- | ||
| 3 | This software is in the public domain under CC0 1.0 Universal plus a | ||
| 4 | Grant of Patent License. | ||
| 5 | |||
| 6 | To the extent possible under law, author(s) have dedicated all | ||
| 7 | copyright and related and neighboring rights to this software to the | ||
| 8 | public domain worldwide. This software is distributed without any | ||
| 9 | warranty. | ||
| 10 | --> | ||
| 11 | <services xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-3.xsd"> | ||
| 12 | |||
| 13 | <service verb="mcp" noun="ResolveScreenPath" authenticate="false" allow-remote="true" transaction-timeout="30"> | ||
| 14 | <description>Resolve a simple screen path to component path, subscreen name, and screen definition.</description> | ||
| 15 | <in-parameters> | ||
| 16 | <parameter name="path" required="true"/> | ||
| 17 | </in-parameters> | ||
| 18 | <out-parameters> | ||
| 19 | <parameter name="result" type="Map"/> | ||
| 20 | </out-parameters> | ||
| 21 | <actions> | ||
| 22 | <script><![CDATA[ | ||
| 23 | import org.moqui.context.ExecutionContext | ||
| 24 | |||
| 25 | ExecutionContext ec = context.ec | ||
| 26 | def resolvedPath = null | ||
| 27 | def subscreenName = null | ||
| 28 | def screenDef = null | ||
| 29 | |||
| 30 | if (!path || path == "root") { | ||
| 31 | return [ | ||
| 32 | screenPath: null, | ||
| 33 | subscreenName: null, | ||
| 34 | screenDef: null | ||
| 35 | ] | ||
| 36 | } | ||
| 37 | |||
| 38 | // Support both dot and slash notation | ||
| 39 | def pathParts = path.contains('/') | ||
| 40 | ? path.split('/') | ||
| 41 | : path.split('\\.') | ||
| 42 | def componentName = pathParts[0] | ||
| 43 | |||
| 44 | // Use longest prefix match to find actual screen file | ||
| 45 | for (int i = pathParts.size(); i >= 1; i--) { | ||
| 46 | def subPath = i > 1 ? pathParts[0] + "/" + (pathParts[1..<i].join('/')) : pathParts[0] | ||
| 47 | def currentTry = "component://${componentName}/screen/${subPath}.xml" | ||
| 48 | if (ec.resource.getLocationReference(currentTry).getExists()) { | ||
| 49 | resolvedPath = currentTry | ||
| 50 | // If we found a screen matching the full path, we're already at the target | ||
| 51 | if (i < pathParts.size()) { | ||
| 52 | def remainingParts = pathParts[i..-1] | ||
| 53 | subscreenName = remainingParts.size() > 1 ? remainingParts.join('_') : remainingParts[0] | ||
| 54 | } | ||
| 55 | break | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | // Fallback to component root screen if no match found | ||
| 60 | if (!resolvedPath) { | ||
| 61 | resolvedPath = "component://${componentName}/screen/${componentName}.xml" | ||
| 62 | if (pathParts.size() > 1) { | ||
| 63 | subscreenName = pathParts[1..-1].join('_') | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | // Navigate to subscreen if needed | ||
| 68 | if (resolvedPath) { | ||
| 69 | try { | ||
| 70 | screenDef = ec.screen.getScreenDefinition(resolvedPath) | ||
| 71 | if (subscreenName && screenDef) { | ||
| 72 | for (subName in subscreenName.split('_')) { | ||
| 73 | def subItem = screenDef?.getSubscreensItem(subName) | ||
| 74 | if (subItem && subItem.getLocation()) { | ||
| 75 | screenDef = ec.screen.getScreenDefinition(subItem.getLocation()) | ||
| 76 | } else { | ||
| 77 | break | ||
| 78 | } | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } catch (Exception e) { | ||
| 82 | ec.logger.warn("ResolveScreenPath: Error loading screen definition: ${e.message}") | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | result = [ | ||
| 87 | screenPath: resolvedPath, | ||
| 88 | subscreenName: subscreenName, | ||
| 89 | screenDef: screenDef | ||
| 90 | ] | ||
| 91 | ]]></script> | ||
| 92 | </actions> | ||
| 93 | </service> | ||
| 94 | |||
| 95 | </services> |
| ... | @@ -121,7 +121,7 @@ | ... | @@ -121,7 +121,7 @@ |
| 121 | 121 | ||
| 122 | // Fallback to hardcoded instructions if wiki not available | 122 | // Fallback to hardcoded instructions if wiki not available |
| 123 | if (!instructions) { | 123 | if (!instructions) { |
| 124 | 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." | 124 | instructions = "This server provides access to Moqui ERP through MCP. Use moqui_browse_screens(path='/PopCommerce') to begin. Key screens include: /PopCommerce/Catalog/Product/FindProduct for products, /PopCommerce/Order/FindOrder for orders, and /PopCommerce/Customer for customer management. All screens support parameterized queries for filtering results." |
| 125 | } | 125 | } |
| 126 | 126 | ||
| 127 | // Build server capabilities - don't fetch actual tools/resources during init | 127 | // Build server capabilities - don't fetch actual tools/resources during init |
| ... | @@ -178,84 +178,23 @@ | ... | @@ -178,84 +178,23 @@ |
| 178 | ec.logger.info("MCP ToolsCall: Dispatching tool name=${name}, arguments=${arguments}") | 178 | ec.logger.info("MCP ToolsCall: Dispatching tool name=${name}, arguments=${arguments}") |
| 179 | ec.logger.info("MCP ToolsCall: CODE VERSION: 2025-01-09 - FIXED NULL CHECK") | 179 | ec.logger.info("MCP ToolsCall: CODE VERSION: 2025-01-09 - FIXED NULL CHECK") |
| 180 | 180 | ||
| 181 | if (name == "moqui_render_screen") { | 181 | if (name == "moqui_render_screen" || name == "moqui_browse_screens") { |
| 182 | def screenPath = arguments?.path | 182 | def targetServiceName = name == "moqui_browse_screens" ? "McpServices.mcp#BrowseScreens" : "McpServices.execute#ScreenAsMcpTool" |
| 183 | def parameters = arguments?.parameters ?: [:] | 183 | def serviceResult = ec.service.sync().name(targetServiceName).parameters(arguments ?: [:]).call() |
| 184 | def renderMode = arguments?.renderMode ?: "mcp" | ||
| 185 | def subscreenName = arguments?.subscreenName | ||
| 186 | def terse = arguments?.terse ?: false | ||
| 187 | |||
| 188 | if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter") | ||
| 189 | |||
| 190 | ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}, terse=${terse}") | ||
| 191 | |||
| 192 | // Strip query parameters from path if present | ||
| 193 | if (screenPath.contains("?")) { | ||
| 194 | screenPath = screenPath.split("\\?")[0] | ||
| 195 | } | ||
| 196 | 184 | ||
| 197 | // Handle component:// or simple dot notation path | 185 | // Ensure standard MCP response format with content array |
| 198 | def resolvedPath = screenPath | 186 | def actualRes = serviceResult?.result ?: serviceResult |
| 199 | def resolvedSubscreen = subscreenName | 187 | if (actualRes instanceof Map && actualRes.content && actualRes.content instanceof List) { |
| 200 | 188 | result = actualRes | |
| 201 | ec.logger.info("MCP ToolsCall: Starting path resolution, screenPath=${screenPath}, resolvedPath=${resolvedPath}") | 189 | } else { |
| 202 | 190 | result = [ content: [[type: "text", text: new groovy.json.JsonBuilder(actualRes).toString()]], isError: false ] | |
| 203 | if (resolvedPath && !resolvedPath.startsWith("component://")) { | ||
| 204 | // Simple dot notation or path conversion | ||
| 205 | // Longest prefix match for XML screen files | ||
| 206 | def pathParts = resolvedPath.split('\\.') | ||
| 207 | def componentName = pathParts[0] | ||
| 208 | def bestPath = null | ||
| 209 | def bestSubscreen = null | ||
| 210 | |||
| 211 | // Start from the longest possible XML path and work backwards | ||
| 212 | for (int i = pathParts.size(); i >= 1; i--) { | ||
| 213 | def subPath = i > 1 ? pathParts[0] + "/" + (pathParts[1..<i].join('/')) : pathParts[0] | ||
| 214 | def currentTry = "component://${componentName}/screen/${subPath}.xml" | ||
| 215 | if (ec.resource.getLocationReference(currentTry).getExists()) { | ||
| 216 | bestPath = currentTry | ||
| 217 | // If we found a screen matching the full path, we're already at the target | ||
| 218 | if (i < pathParts.size()) { | ||
| 219 | bestSubscreen = pathParts[i..-1].join('_') | ||
| 220 | } else { | ||
| 221 | bestSubscreen = null | ||
| 222 | } | ||
| 223 | break | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | if (bestPath) { | ||
| 228 | resolvedPath = bestPath | ||
| 229 | resolvedSubscreen = bestSubscreen | ||
| 230 | } else { | ||
| 231 | // Fallback to original logic if nothing found | ||
| 232 | resolvedPath = "component://${componentName}/screen/${componentName}.xml" | ||
| 233 | resolvedSubscreen = pathParts.size() > 1 ? pathParts[1..-1].join('_') : null | ||
| 234 | } | ||
| 235 | } | 191 | } |
| 236 | |||
| 237 | def screenCallParams = [ | ||
| 238 | path: resolvedPath, | ||
| 239 | parameters: parameters, | ||
| 240 | renderMode: renderMode, | ||
| 241 | sessionId: sessionId, | ||
| 242 | terse: terse | ||
| 243 | ] | ||
| 244 | if (resolvedSubscreen) screenCallParams.subscreenName = resolvedSubscreen | ||
| 245 | |||
| 246 | def serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") | ||
| 247 | .parameters(screenCallParams).call() | ||
| 248 | |||
| 249 | // ScreenAsMcpTool returns the final result map directly | ||
| 250 | result = serviceResult | ||
| 251 | return | 192 | return |
| 252 | } | 193 | } |
| 253 | 194 | ||
| 254 | // Handle internal discovery/utility tools | 195 | // Handle internal discovery/utility tools |
| 255 | def internalToolMappings = [ | 196 | def internalToolMappings = [ |
| 256 | "moqui_browse_screens": "McpServices.mcp#BrowseScreens", | 197 | "moqui_search_screens": "McpServices.mcp#SearchScreens" |
| 257 | "moqui_search_screens": "McpServices.mcp#SearchScreens", | ||
| 258 | "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails" | ||
| 259 | ] | 198 | ] |
| 260 | 199 | ||
| 261 | def targetServiceName = internalToolMappings[name] | 200 | def targetServiceName = internalToolMappings[name] |
| ... | @@ -429,7 +368,7 @@ | ... | @@ -429,7 +368,7 @@ |
| 429 | 368 | ||
| 430 | // Fallback to hardcoded instructions | 369 | // Fallback to hardcoded instructions |
| 431 | if (!instructionsText) { | 370 | if (!instructionsText) { |
| 432 | instructionsText = "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." | 371 | instructionsText = "This server provides access to Moqui ERP through MCP. Use moqui_browse_screens(path='/PopCommerce') to begin. Key screens include: /PopCommerce/Catalog/Product/FindProduct for products, /PopCommerce/Order/FindOrder for orders, and /PopCommerce/Customer for customer management. All screens support parameterized queries for filtering results." |
| 433 | } | 372 | } |
| 434 | 373 | ||
| 435 | result = [ | 374 | result = [ |
| ... | @@ -621,95 +560,6 @@ | ... | @@ -621,95 +560,6 @@ |
| 621 | </actions> | 560 | </actions> |
| 622 | </service> | 561 | </service> |
| 623 | 562 | ||
| 624 | <!-- Helper Functions --> | ||
| 625 | |||
| 626 | <service verb="validate" noun="Origin" authenticate="false" allow-remote="false"> | ||
| 627 | <description>Validate Origin header for DNS rebinding protection</description> | ||
| 628 | <in-parameters> | ||
| 629 | <parameter name="origin" required="true"/> | ||
| 630 | </in-parameters> | ||
| 631 | <out-parameters> | ||
| 632 | <parameter name="isValid" type="boolean"/> | ||
| 633 | </out-parameters> | ||
| 634 | <actions> | ||
| 635 | <script><![CDATA[ | ||
| 636 | import org.moqui.context.ExecutionContext | ||
| 637 | import org.moqui.impl.context.UserFacadeImpl.UserInfo | ||
| 638 | |||
| 639 | ExecutionContext ec = context.ec | ||
| 640 | |||
| 641 | // Allow localhost origins | ||
| 642 | if (origin?.startsWith("http://localhost:") || origin?.startsWith("https://localhost:")) { | ||
| 643 | isValid = true | ||
| 644 | return | ||
| 645 | } | ||
| 646 | |||
| 647 | // Allow 127.0.0.1 origins | ||
| 648 | if (origin?.startsWith("http://127.0.0.1:") || origin?.startsWith("https://127.0.0.1:")) { | ||
| 649 | isValid = true | ||
| 650 | return | ||
| 651 | } | ||
| 652 | |||
| 653 | // Allow same-origin requests (check against current host) | ||
| 654 | def currentHost = ec.web?.request?.getServerName() | ||
| 655 | def currentScheme = ec.web?.request?.getScheme() | ||
| 656 | def currentPort = ec.web?.request?.getServerPort() | ||
| 657 | |||
| 658 | def expectedOrigin = "${currentScheme}://${currentHost}" | ||
| 659 | if ((currentScheme == "http" && currentPort != 80) || (currentScheme == "https" && currentPort != 443)) { | ||
| 660 | expectedOrigin += ":${currentPort}" | ||
| 661 | } | ||
| 662 | |||
| 663 | if (origin == expectedOrigin) { | ||
| 664 | isValid = true | ||
| 665 | return | ||
| 666 | } | ||
| 667 | |||
| 668 | // Check for configured allowed origins (could be from system properties) | ||
| 669 | def allowedOrigins = ec.getFactory().getConfiguration().getStringList("moqui.mcp.allowed_origins", []) | ||
| 670 | if (allowedOrigins.contains(origin)) { | ||
| 671 | isValid = true | ||
| 672 | return | ||
| 673 | } | ||
| 674 | |||
| 675 | isValid = false | ||
| 676 | ]]></script> | ||
| 677 | </actions> | ||
| 678 | </service> | ||
| 679 | |||
| 680 | <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="false"> | ||
| 681 | <description>Convert Moqui data types to JSON Schema types</description> | ||
| 682 | <in-parameters> | ||
| 683 | <parameter name="moquiType" required="true"/> | ||
| 684 | </in-parameters> | ||
| 685 | <out-parameters> | ||
| 686 | <parameter name="jsonSchemaType"/> | ||
| 687 | </out-parameters> | ||
| 688 | <actions> | ||
| 689 | <script><![CDATA[ | ||
| 690 | // Simple type mapping - can be expanded as needed | ||
| 691 | def typeMap = [ | ||
| 692 | "text-short": "string", | ||
| 693 | "text-medium": "string", | ||
| 694 | "text-long": "string", | ||
| 695 | "text-very-long": "string", | ||
| 696 | "id": "string", | ||
| 697 | "id-long": "string", | ||
| 698 | "number-integer": "integer", | ||
| 699 | "number-decimal": "number", | ||
| 700 | "number-float": "number", | ||
| 701 | "date": "string", | ||
| 702 | "date-time": "string", | ||
| 703 | "date-time-nano": "string", | ||
| 704 | "boolean": "boolean", | ||
| 705 | "text-indicator": "boolean" | ||
| 706 | ] | ||
| 707 | |||
| 708 | jsonSchemaType = typeMap[moquiType] ?: "string" | ||
| 709 | ]]></script> | ||
| 710 | </actions> | ||
| 711 | </service> | ||
| 712 | |||
| 713 | <service verb="execute" noun="ScreenAsMcpTool" authenticate="true" allow-remote="true" transaction-timeout="120"> | 563 | <service verb="execute" noun="ScreenAsMcpTool" authenticate="true" allow-remote="true" transaction-timeout="120"> |
| 714 | <description>Execute a screen as an MCP tool</description> | 564 | <description>Execute a screen as an MCP tool</description> |
| 715 | <in-parameters> | 565 | <in-parameters> |
| ... | @@ -718,7 +568,6 @@ | ... | @@ -718,7 +568,6 @@ |
| 718 | <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> | 568 | <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> |
| 719 | <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> | 569 | <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> |
| 720 | <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> | 570 | <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> |
| 721 | <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter> | ||
| 722 | <parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter> | 571 | <parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter> |
| 723 | </in-parameters> | 572 | </in-parameters> |
| 724 | <out-parameters> | 573 | <out-parameters> |
| ... | @@ -738,6 +587,9 @@ if (parameters) { | ... | @@ -738,6 +587,9 @@ if (parameters) { |
| 738 | ec.context.putAll(parameters) | 587 | ec.context.putAll(parameters) |
| 739 | } | 588 | } |
| 740 | 589 | ||
| 590 | // Map path parameter to screenPath for consistency | ||
| 591 | def screenPath = path | ||
| 592 | |||
| 741 | // Helper function to get simple path from component path | 593 | // Helper function to get simple path from component path |
| 742 | def getSimplePath = { fullPath -> | 594 | def getSimplePath = { fullPath -> |
| 743 | if (!fullPath || fullPath == "root") return "root" | 595 | if (!fullPath || fullPath == "root") return "root" |
| ... | @@ -746,15 +598,15 @@ def getSimplePath = { fullPath -> | ... | @@ -746,15 +598,15 @@ def getSimplePath = { fullPath -> |
| 746 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) | 598 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) |
| 747 | List<String> parts = cleanPath.split('/').toList() | 599 | List<String> parts = cleanPath.split('/').toList() |
| 748 | if (parts.size() > 1 && parts[1] == "screen") parts.remove(1) | 600 | if (parts.size() > 1 && parts[1] == "screen") parts.remove(1) |
| 749 | return parts.join('.') | 601 | return parts.join('/') |
| 750 | } | 602 | } |
| 751 | 603 | ||
| 752 | // Helper function to load wiki instructions for a screen | 604 | // Helper function to load wiki instructions for a screen |
| 753 | def getWikiInstructions = { screenPath -> | 605 | def getWikiInstructions = { lookupPath -> |
| 754 | try { | 606 | try { |
| 755 | def wikiPage = ec.entity.find("moqui.resource.wiki.WikiPage") | 607 | def wikiPage = ec.entity.find("moqui.resource.wiki.WikiPage") |
| 756 | .condition("wikiSpaceId", "MCP_SCREEN_DOCS") | 608 | .condition("wikiSpaceId", "MCP_SCREEN_DOCS") |
| 757 | .condition("pagePath", screenPath) | 609 | .condition("pagePath", lookupPath) |
| 758 | .useCache(true) | 610 | .useCache(true) |
| 759 | .one() | 611 | .one() |
| 760 | 612 | ||
| ... | @@ -888,96 +740,321 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -888,96 +740,321 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 888 | def output = null | 740 | def output = null |
| 889 | def screenUrl = "http://localhost:8080/${screenPath}" | 741 | def screenUrl = "http://localhost:8080/${screenPath}" |
| 890 | def isError = false | 742 | def isError = false |
| 743 | def resolvedScreenDef = null | ||
| 891 | 744 | ||
| 892 | try { | 745 | try { |
| 893 | ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen") | 746 | ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath}") |
| 894 | 747 | ||
| 895 | def testScreenPath = screenPath | ||
| 896 | def rootScreen = "component://webroot/screen/webroot.xml" | 748 | def rootScreen = "component://webroot/screen/webroot.xml" |
| 897 | 749 | def testScreenPath = screenPath | |
| 898 | def targetScreenDef = null | ||
| 899 | def isStandalone = false | ||
| 900 | 750 | ||
| 901 | if (screenPath.startsWith("component://")) { | 751 | if (screenPath.startsWith("component://")) { |
| 902 | def pathAfterComponent = screenPath.substring(12).replace('.xml','') | 752 | // Component path handling |
| 903 | 753 | resolvedScreenDef = ec.screen.getScreenDefinition(screenPath) | |
| 904 | try { | 754 | rootScreen = screenPath |
| 905 | targetScreenDef = ec.screen.getScreenDefinition(screenPath) | 755 | testScreenPath = "" |
| 906 | if (targetScreenDef?.screenNode) { | 756 | } else { |
| 907 | def standaloneAttr = targetScreenDef.screenNode.attribute('standalone') | 757 | // Forward slash path handling (e.g. /PopCommerce/Catalog) |
| 908 | isStandalone = standaloneAttr == "true" | 758 | def testPath = screenPath.startsWith('/') ? screenPath : "/" + screenPath |
| 759 | def pathSegments = [] | ||
| 760 | testPath.split('/').each { if (it && it.trim()) pathSegments.add(it) } | ||
| 761 | |||
| 762 | // 1. Try literal resolution from webroot | ||
| 763 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 764 | def webrootSd = ec.screen.getScreenDefinition(rootScreen) | ||
| 765 | def screenPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 766 | webrootSd, webrootSd, pathSegments, testPath, [:], ec.screenFacade | ||
| 767 | ) | ||
| 768 | |||
| 769 | def currentSd = webrootSd | ||
| 770 | def reachedIndex = -1 | ||
| 771 | if (screenPathList) { | ||
| 772 | for (int i = 0; i < screenPathList.size(); i++) { | ||
| 773 | def screenName = screenPathList[i] | ||
| 774 | def ssi = currentSd?.getSubscreensItem(screenName) | ||
| 775 | if (ssi && ssi.getLocation()) { | ||
| 776 | currentSd = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 777 | reachedIndex = i | ||
| 778 | } else { | ||
| 779 | break | ||
| 780 | } | ||
| 909 | } | 781 | } |
| 782 | } | ||
| 783 | |||
| 784 | // 2. If literal resolution failed, try Component-based resolution | ||
| 785 | if (reachedIndex == -1 && pathSegments.size() >= 2) { | ||
| 786 | def componentName = pathSegments[0] | ||
| 787 | def rootScreenName = pathSegments[1] | ||
| 788 | def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml" | ||
| 910 | 789 | ||
| 911 | if (isStandalone) { | 790 | if (ec.resource.getLocationReference(compRootLoc).exists) { |
| 912 | rootScreen = screenPath | 791 | ec.logger.info("MCP Path Resolution: Found component root at ${compRootLoc}") |
| 913 | testScreenPath = "" | 792 | rootScreen = compRootLoc |
| 793 | testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : "" | ||
| 794 | resolvedScreenDef = ec.screen.getScreenDefinition(rootScreen) | ||
| 795 | |||
| 796 | // Resolve further if there are remaining segments | ||
| 797 | if (testScreenPath) { | ||
| 798 | def remainingSegments = pathSegments[2..-1] | ||
| 799 | def compPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 800 | resolvedScreenDef, resolvedScreenDef, remainingSegments, testScreenPath, [:], ec.screenFacade | ||
| 801 | ) | ||
| 802 | if (compPathList) { | ||
| 803 | for (String screenName in compPathList) { | ||
| 804 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 805 | if (ssi && ssi.getLocation()) { | ||
| 806 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 807 | } else { | ||
| 808 | break | ||
| 809 | } | ||
| 810 | } | ||
| 811 | } | ||
| 812 | } | ||
| 914 | } | 813 | } |
| 915 | } catch (Exception e) { | ||
| 916 | ec.logger.warn("MCP Screen Execution: Error checking target screen ${screenPath}: ${e.message}") | ||
| 917 | } | 814 | } |
| 918 | 815 | ||
| 919 | if (!isStandalone) { | 816 | // 3. Fallback to double-slash search if still not found |
| 920 | try { | 817 | if (reachedIndex == -1 && !resolvedScreenDef && pathSegments.size() > 0 && !testPath.startsWith("//")) { |
| 921 | if (ec.screen.getScreenDefinition(screenPath)) { | 818 | def searchPath = "//" + pathSegments.join('/') |
| 922 | rootScreen = screenPath | 819 | ec.logger.info("MCP Path Resolution: Fallback to search path ${searchPath}") |
| 923 | testScreenPath = "" | 820 | |
| 924 | } else { | 821 | rootScreen = "component://webroot/screen/webroot.xml" |
| 925 | if (pathAfterComponent.startsWith("webroot/screen/")) { | 822 | def searchPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( |
| 926 | rootScreen = "component://webroot/screen/webroot.xml" | 823 | webrootSd, webrootSd, pathSegments, searchPath, [:], ec.screenFacade |
| 927 | testScreenPath = pathAfterComponent.substring("webroot/screen/".length()) | 824 | ) |
| 928 | if (testScreenPath.startsWith("webroot/")) { | 825 | |
| 929 | testScreenPath = testScreenPath.substring("webroot/".length()) | 826 | if (searchPathList) { |
| 930 | } | 827 | testScreenPath = searchPath |
| 828 | resolvedScreenDef = webrootSd | ||
| 829 | for (String screenName in searchPathList) { | ||
| 830 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 831 | if (ssi && ssi.getLocation()) { | ||
| 832 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 931 | } else { | 833 | } else { |
| 932 | rootScreen = screenPath | 834 | break |
| 933 | testScreenPath = "" | ||
| 934 | } | 835 | } |
| 935 | } | 836 | } |
| 936 | } catch (Exception e) {} | 837 | } |
| 937 | } | 838 | } |
| 938 | 839 | ||
| 840 | // If we found a specific target, we're good. | ||
| 841 | // If not, default to webroot with full path (original behavior, but now we know it failed) | ||
| 842 | if (!resolvedScreenDef) { | ||
| 843 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 844 | resolvedScreenDef = webrootSd | ||
| 845 | testScreenPath = testPath | ||
| 846 | } | ||
| 847 | } | ||
| 848 | |||
| 849 | // Regular screen rendering with current user context - use our custom ScreenTestImpl | ||
| 850 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) | ||
| 851 | .rootScreen(rootScreen) | ||
| 852 | .renderMode(renderMode ?: "mcp") | ||
| 853 | .auth(ec.user.username) | ||
| 854 | |||
| 855 | def renderParams = parameters ?: [:] | ||
| 856 | renderParams.userId = ec.user.userId | ||
| 857 | renderParams.username = ec.user.username | ||
| 858 | |||
| 859 | def relativePath = testScreenPath | ||
| 860 | ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}") | ||
| 861 | |||
| 862 | def testRender = screenTest.render(relativePath, renderParams, "POST") | ||
| 863 | output = testRender.getOutput() | ||
| 864 | |||
| 865 | // --- NEW: Semantic State Extraction --- | ||
| 866 | def postContext = testRender.getPostRenderContext() | ||
| 867 | def semanticState = [:] | ||
| 868 | def isTerse = context.terse == true | ||
| 869 | |||
| 870 | // Get final screen definition using resolved screen location | ||
| 871 | def finalScreenDef = resolvedScreenDef | ||
| 872 | |||
| 873 | if (finalScreenDef && postContext) { | ||
| 874 | semanticState.screenPath = inputScreenPath | ||
| 875 | semanticState.terse = isTerse | ||
| 876 | semanticState.data = [:] | ||
| 877 | |||
| 878 | // Use the explicit semantic data captured by macros if available | ||
| 879 | def explicitData = postContext.get("mcpSemanticData") | ||
| 880 | if (explicitData instanceof Map) { | ||
| 881 | explicitData.each { k, v -> | ||
| 882 | semanticState.data[k] = serializeMoquiObject(v, 0, isTerse) | ||
| 883 | } | ||
| 884 | } | ||
| 885 | |||
| 886 | // Extract transitions (Actions) with metadata (from screen definition, not macros) | ||
| 887 | semanticState.actions = [] | ||
| 888 | finalScreenDef.getAllTransitions().each { trans -> | ||
| 889 | def actionInfo = [ | ||
| 890 | name: trans.getName(), | ||
| 891 | service: trans.getSingleServiceName() | ||
| 892 | ] | ||
| 893 | semanticState.actions << actionInfo | ||
| 894 | } | ||
| 895 | |||
| 896 | // 3. Extract parameters that are currently set | ||
| 897 | semanticState.parameters = [:] | ||
| 898 | if (finalScreenDef.parameterByName) { | ||
| 899 | finalScreenDef.parameterByName.each { name, param -> | ||
| 900 | def value = postContext.get(name) ?: parameters?.get(name) | ||
| 901 | if (value != null) semanticState.parameters[name] = serializeMoquiObject(value, 0, isTerse) | ||
| 902 | } | ||
| 903 | } | ||
| 904 | |||
| 905 | // Log semantic state size for optimization tracking | ||
| 906 | def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString() | ||
| 907 | def semanticStateSize = semanticStateJson.length() | ||
| 908 | ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}") | ||
| 909 | } | ||
| 910 | |||
| 911 | ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}") | ||
| 912 | |||
| 913 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 914 | |||
| 915 | // Build result based on renderMode | ||
| 916 | def content = [] | ||
| 917 | if ((renderMode == "mcp" || renderMode == "json") && semanticState) { | ||
| 918 | // Return structured MCP data | ||
| 919 | def mcpResult = [ | ||
| 920 | screenPath: screenPath, | ||
| 921 | screenUrl: screenUrl, | ||
| 922 | executionTime: executionTime, | ||
| 923 | isError: isError, | ||
| 924 | semanticState: semanticState | ||
| 925 | ] | ||
| 926 | |||
| 927 | // Truncate text preview to 500 chars to save tokens, since we have structured data | ||
| 928 | if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "") | ||
| 929 | if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions | ||
| 930 | |||
| 931 | content << [ | ||
| 932 | type: "text", | ||
| 933 | text: new groovy.json.JsonBuilder(mcpResult).toString() | ||
| 934 | ] | ||
| 939 | } else { | 935 | } else { |
| 940 | rootScreen = screenPath | 936 | // Return raw output for other modes (text, html, etc) |
| 941 | testScreenPath = "" | 937 | def textOutput = output |
| 938 | if (wikiInstructions) { | ||
| 939 | textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}" | ||
| 940 | } | ||
| 941 | content << [ | ||
| 942 | type: "text", | ||
| 943 | text: textOutput, | ||
| 944 | screenPath: screenPath, | ||
| 945 | screenUrl: screenUrl, | ||
| 946 | executionTime: executionTime, | ||
| 947 | isError: isError | ||
| 948 | ] | ||
| 942 | } | 949 | } |
| 943 | 950 | ||
| 944 | // Get final screen definition for data extraction | 951 | result = [ |
| 945 | def finalScreenDef = rootScreen ? ec.screen.getScreenDefinition(rootScreen) : null | 952 | content: content, |
| 946 | if (finalScreenDef && testScreenPath) { | 953 | isError: false |
| 947 | def pathSegments = testScreenPath.split('/') | 954 | ] |
| 948 | for (segment in pathSegments) { | 955 | return // Success! |
| 949 | if (finalScreenDef) { | 956 | |
| 950 | def subItem = finalScreenDef?.getSubscreensItem(segment) | 957 | } catch (Exception e) { |
| 951 | if (subItem && subItem.getLocation()) { | 958 | isError = true |
| 952 | finalScreenDef = ec.screen.getScreenDefinition(subItem.getLocation()) | 959 | ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e) |
| 953 | } else { | 960 | output = "SCREEN RENDERING ERROR: ${e.message}" |
| 954 | break | 961 | result = [ |
| 962 | isError: true, | ||
| 963 | content: [[type: "text", text: output]] | ||
| 964 | ] | ||
| 965 | } | ||
| 966 | } | ||
| 967 | } | ||
| 968 | |||
| 969 | // 2. If literal resolution failed, try Component-based resolution | ||
| 970 | if (reachedIndex == -1 && pathSegments.size() >= 2) { | ||
| 971 | def componentName = pathSegments[0] | ||
| 972 | def rootScreenName = pathSegments[1] | ||
| 973 | def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml" | ||
| 974 | |||
| 975 | if (ec.resource.getLocationReference(compRootLoc).exists) { | ||
| 976 | ec.logger.info("MCP Path Resolution: Found component root at ${compRootLoc}") | ||
| 977 | rootScreen = compRootLoc | ||
| 978 | testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : "" | ||
| 979 | resolvedScreenDef = ec.screen.getScreenDefinition(rootScreen) | ||
| 980 | |||
| 981 | // Resolve further if there are remaining segments | ||
| 982 | if (testScreenPath) { | ||
| 983 | def remainingSegments = pathSegments[2..-1] | ||
| 984 | def compPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 985 | resolvedScreenDef, resolvedScreenDef, remainingSegments, testScreenPath, [:], ec.screenFacade | ||
| 986 | ) | ||
| 987 | if (compPathList) { | ||
| 988 | for (String screenName in compPathList) { | ||
| 989 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 990 | if (ssi && ssi.getLocation()) { | ||
| 991 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 992 | } else { | ||
| 993 | break | ||
| 994 | } | ||
| 995 | } | ||
| 996 | } | ||
| 955 | } | 997 | } |
| 956 | } | 998 | } |
| 957 | } | 999 | } |
| 1000 | |||
| 1001 | // 3. Fallback to double-slash search if still not found | ||
| 1002 | if (reachedIndex == -1 && !resolvedScreenDef && pathSegments.size() > 0 && !testPath.startsWith("//")) { | ||
| 1003 | def searchPath = "//" + pathSegments.join('/') | ||
| 1004 | ec.logger.info("MCP Path Resolution: Fallback to search path ${searchPath}") | ||
| 1005 | |||
| 1006 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 1007 | def searchPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 1008 | webrootSd, webrootSd, pathSegments, searchPath, [:], ec.screenFacade | ||
| 1009 | ) | ||
| 1010 | |||
| 1011 | if (searchPathList) { | ||
| 1012 | testScreenPath = searchPath | ||
| 1013 | resolvedScreenDef = webrootSd | ||
| 1014 | for (String screenName in searchPathList) { | ||
| 1015 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 1016 | if (ssi && ssi.getLocation()) { | ||
| 1017 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 1018 | } else { | ||
| 1019 | break | ||
| 1020 | } | ||
| 1021 | } | ||
| 1022 | } | ||
| 1023 | } | ||
| 1024 | |||
| 1025 | // If we found a specific target, we're good. | ||
| 1026 | // If not, default to webroot with full path (original behavior, but now we know it failed) | ||
| 1027 | if (!resolvedScreenDef) { | ||
| 1028 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 1029 | resolvedScreenDef = webrootSd | ||
| 1030 | testScreenPath = testPath | ||
| 1031 | } | ||
| 958 | } | 1032 | } |
| 959 | 1033 | ||
| 960 | // Regular screen rendering with current user context - use our custom ScreenTestImpl | 1034 | // Regular screen rendering with current user context - use our custom ScreenTestImpl |
| 961 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) | 1035 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) |
| 962 | .rootScreen(rootScreen) | 1036 | .rootScreen(rootScreen) |
| 963 | .renderMode(renderMode ? renderMode : "mcp") | 1037 | .renderMode(renderMode ?: "mcp") |
| 964 | .auth(ec.user.username) | 1038 | .auth(ec.user.username) |
| 965 | 1039 | ||
| 966 | def renderParams = parameters ?: [:] | 1040 | def renderParams = parameters ?: [:] |
| 967 | renderParams.userId = ec.user.userId | 1041 | renderParams.userId = ec.user.userId |
| 968 | renderParams.username = ec.user.username | 1042 | renderParams.username = ec.user.username |
| 969 | 1043 | ||
| 970 | def relativePath = subscreenName ? subscreenName.replaceAll('_','/') : testScreenPath | 1044 | def relativePath = testScreenPath |
| 971 | ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}") | 1045 | ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}") |
| 972 | 1046 | ||
| 973 | def testRender = screenTest.render(relativePath, renderParams, "POST") | 1047 | def testRender = screenTest.render(relativePath, renderParams, "POST") |
| 974 | output = testRender.getOutput() | 1048 | output = testRender.getOutput() |
| 975 | 1049 | ||
| 976 | // --- NEW: Semantic State Extraction --- | 1050 | // --- NEW: Semantic State Extraction --- |
| 977 | def postContext = testRender.getPostRenderContext() | 1051 | def postContext = testRender.getPostRenderContext() |
| 978 | def semanticState = [:] | 1052 | def semanticState = [:] |
| 979 | def isTerse = context.terse == true | 1053 | def isTerse = context.terse == true |
| 980 | 1054 | ||
| 1055 | // Get final screen definition using resolved screen location | ||
| 1056 | def finalScreenDef = resolvedScreenDef | ||
| 1057 | |||
| 981 | if (finalScreenDef && postContext) { | 1058 | if (finalScreenDef && postContext) { |
| 982 | semanticState.screenPath = inputScreenPath | 1059 | semanticState.screenPath = inputScreenPath |
| 983 | semanticState.terse = isTerse | 1060 | semanticState.terse = isTerse |
| ... | @@ -1439,7 +1516,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1439,7 +1516,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1439 | return null | 1516 | return null |
| 1440 | } | 1517 | } |
| 1441 | 1518 | ||
| 1442 | // Helper to convert full component path to simple path (PopCommerce/screen/Root.xml -> PopCommerce.Root) | 1519 | // Helper to convert full component path to simple path (PopCommerce/screen/Root.xml -> PopCommerce/Root) |
| 1443 | def convertToSimplePath = { fullPath -> | 1520 | def convertToSimplePath = { fullPath -> |
| 1444 | if (!fullPath) return null | 1521 | if (!fullPath) return null |
| 1445 | String cleanPath = fullPath | 1522 | String cleanPath = fullPath |
| ... | @@ -1447,7 +1524,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1447,7 +1524,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1447 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) | 1524 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) |
| 1448 | List<String> parts = cleanPath.split('/').toList() | 1525 | List<String> parts = cleanPath.split('/').toList() |
| 1449 | if (parts.size() > 1 && parts[1] == "screen") parts.remove(1) | 1526 | if (parts.size() > 1 && parts[1] == "screen") parts.remove(1) |
| 1450 | return parts.join('.') | 1527 | return parts.join('/') |
| 1451 | } | 1528 | } |
| 1452 | 1529 | ||
| 1453 | // Helper to extract short description from wiki content | 1530 | // Helper to extract short description from wiki content |
| ... | @@ -1463,6 +1540,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1463,6 +1540,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1463 | return null | 1540 | return null |
| 1464 | } | 1541 | } |
| 1465 | 1542 | ||
| 1543 | def resolvedScreenDef = null | ||
| 1466 | if (currentPath == "root") { | 1544 | if (currentPath == "root") { |
| 1467 | // Discover top-level applications | 1545 | // Discover top-level applications |
| 1468 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") | 1546 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") |
| ... | @@ -1487,8 +1565,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1487,8 +1565,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1487 | } | 1565 | } |
| 1488 | } | 1566 | } |
| 1489 | 1567 | ||
| 1490 | /* Removed hardcoded list - rely on proper user permissions and artifact discovery */ | ||
| 1491 | |||
| 1492 | for (def screenPath in rootScreens) { | 1568 | for (def screenPath in rootScreens) { |
| 1493 | def simplePath = convertToSimplePath(screenPath) | 1569 | def simplePath = convertToSimplePath(screenPath) |
| 1494 | def wikiContent = loadWikiContent(simplePath) | 1570 | def wikiContent = loadWikiContent(simplePath) |
| ... | @@ -1499,196 +1575,95 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1499,196 +1575,95 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1499 | ] | 1575 | ] |
| 1500 | } | 1576 | } |
| 1501 | } else { | 1577 | } else { |
| 1502 | // Resolve simple path to component path using longest match and traversal | 1578 | // Forward slash path resolution using Moqui standard |
| 1503 | def pathParts = path.split('\\.') | 1579 | def webrootSd = ec.screen.getScreenDefinition("component://webroot/screen/webroot.xml") |
| 1504 | def componentName = pathParts[0] | 1580 | def pathSegments = [] |
| 1505 | def baseScreenPath = null | 1581 | currentPath.split('/').each { if (it && it.trim()) pathSegments.add(it) } |
| 1506 | def subParts = [] | ||
| 1507 | 1582 | ||
| 1508 | for (int i = pathParts.size(); i >= 1; i--) { | 1583 | def screenPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( |
| 1509 | def subPath = i > 1 ? pathParts[0] + "/" + (pathParts[1..<i].join('/')) : pathParts[0] | 1584 | webrootSd, webrootSd, pathSegments, currentPath, [:], ec.screenFacade |
| 1510 | def currentTry = "component://${componentName}/screen/${subPath}.xml" | 1585 | ) |
| 1511 | if (ec.resource.getLocationReference(currentTry).getExists()) { | 1586 | |
| 1512 | baseScreenPath = currentTry | 1587 | if (screenPathList) { |
| 1513 | if (i < pathParts.size()) subParts = pathParts[i..-1] | 1588 | resolvedScreenDef = webrootSd |
| 1514 | break | 1589 | for (String screenName in screenPathList) { |
| 1515 | } | 1590 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) |
| 1516 | } | 1591 | if (ssi && ssi.getLocation()) { |
| 1517 | 1592 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | |
| 1518 | if (!baseScreenPath) { | ||
| 1519 | baseScreenPath = "component://${componentName}/screen/${componentName}.xml" | ||
| 1520 | if (pathParts.size() > 1) subParts = pathParts[1..-1] | ||
| 1521 | } | ||
| 1522 | |||
| 1523 | try { | ||
| 1524 | def screenDef = ec.screen.getScreenDefinition(baseScreenPath) | ||
| 1525 | // Traverse subscreens to find the target screen | ||
| 1526 | for (subName in subParts) { | ||
| 1527 | def subItem = screenDef?.getSubscreensItem(subName) | ||
| 1528 | if (subItem && subItem.getLocation()) { | ||
| 1529 | screenDef = ec.screen.getScreenDefinition(subItem.getLocation()) | ||
| 1530 | } else { | 1593 | } else { |
| 1531 | // Subscreen not found or defined in-place | ||
| 1532 | break | 1594 | break |
| 1533 | } | 1595 | } |
| 1534 | } | 1596 | } |
| 1597 | } | ||
| 1535 | 1598 | ||
| 1536 | if (screenDef) { | 1599 | if (resolvedScreenDef) { |
| 1537 | def subItems = screenDef.getSubscreensItemsSorted() | 1600 | resolvedScreenDef.getSubscreensItemsSorted().each { subItem -> |
| 1538 | for (subItem in subItems) { | 1601 | def subName = subItem.getName() |
| 1539 | def subName = subItem.getName() | 1602 | def subPath = currentPath + "/" + subName |
| 1540 | def subPath = currentPath + "." + subName | 1603 | def wikiContent = loadWikiContent(subPath) |
| 1541 | def wikiContent = loadWikiContent(subPath) | 1604 | subscreens << [ |
| 1542 | def description = wikiContent ? getShortDescription(wikiContent) : "Subscreen: ${subName}" | 1605 | path: subPath, |
| 1543 | subscreens << [ | 1606 | description: wikiContent ? getShortDescription(wikiContent) : "Subscreen: ${subName}" |
| 1544 | path: subPath, | 1607 | ] |
| 1545 | description: description | ||
| 1546 | ] | ||
| 1547 | } | ||
| 1548 | } | 1608 | } |
| 1549 | } catch (Exception e) { | ||
| 1550 | ec.logger.warn("Browse error for ${currentPath}: ${e.message}") | ||
| 1551 | } | 1609 | } |
| 1552 | } | 1610 | } |
| 1553 | 1611 | ||
| 1554 | // Process action before rendering - execute transitions directly | 1612 | // Process action before rendering |
| 1555 | def actionResult = null | 1613 | def actionResult = null |
| 1556 | def actionError = null | 1614 | def actionError = null |
| 1557 | 1615 | ||
| 1558 | if (action) { | 1616 | if (action && resolvedScreenDef) { |
| 1559 | try { | 1617 | try { |
| 1560 | ec.logger.info("BrowseScreens: Executing action '${action}' on ${currentPath}") | 1618 | ec.logger.info("BrowseScreens: Executing action '${action}' on ${currentPath}") |
| 1561 | |||
| 1562 | // Resolve screen definition to find transitions | ||
| 1563 | def pathParts = currentPath.split('\\.') | ||
| 1564 | def componentName = pathParts[0] | ||
| 1565 | def screenPath = null | ||
| 1566 | def subscreenName = null | ||
| 1567 | |||
| 1568 | for (int i = pathParts.size(); i >= 1; i--) { | ||
| 1569 | def subPath = i > 1 ? pathParts[0] + "/" + (pathParts[1..<i].join('/')) : pathParts[0] | ||
| 1570 | def currentTry = "component://${componentName}/screen/${subPath}.xml" | ||
| 1571 | if (ec.resource.getLocationReference(currentTry).getExists()) { | ||
| 1572 | screenPath = currentTry | ||
| 1573 | // If we found a screen matching the full path, we're already at the target | ||
| 1574 | if (i < pathParts.size()) { | ||
| 1575 | def remainingParts = pathParts[i..-1] | ||
| 1576 | subscreenName = remainingParts.size() > 1 ? remainingParts.join('_') : remainingParts[0] | ||
| 1577 | } else { | ||
| 1578 | subscreenName = null | ||
| 1579 | } | ||
| 1580 | break | ||
| 1581 | } | ||
| 1582 | } | ||
| 1583 | |||
| 1584 | if (!screenPath) { | ||
| 1585 | screenPath = "component://${componentName}/screen/${componentName}.xml" | ||
| 1586 | if (pathParts.size() > 1) { | ||
| 1587 | subscreenName = pathParts[1..-1].join('_') | ||
| 1588 | } | ||
| 1589 | } | ||
| 1590 | |||
| 1591 | // Get screen definition for finding transitions (use resolved screenPath directly) | ||
| 1592 | def screenDef = ec.screen.getScreenDefinition(screenPath) | ||
| 1593 | |||
| 1594 | // Store screenDef for later use - we don't navigate to subscreen for transition lookup | ||
| 1595 | |||
| 1596 | def foundTransition = null | ||
| 1597 | def actionParams = parameters ?: [:] | 1619 | def actionParams = parameters ?: [:] |
| 1598 | 1620 | ||
| 1599 | // Special handling for "submit" action | ||
| 1600 | if (action == "submit") { | 1621 | if (action == "submit") { |
| 1601 | ec.logger.info("BrowseScreens: Submitting form with parameters: ${actionParams}") | ||
| 1602 | // Build screen context with parameters | ||
| 1603 | actionParams.userId = ec.user.userId | ||
| 1604 | actionParams.username = ec.user.username | ||
| 1605 | |||
| 1606 | // Submit is handled by passing parameters to screen render | ||
| 1607 | actionResult = [ | 1622 | actionResult = [ |
| 1608 | action: "submit", | 1623 | action: "submit", |
| 1609 | status: "success", | 1624 | status: "success", |
| 1610 | message: "Form parameters submitted", | 1625 | message: "Form parameters submitted", |
| 1611 | parametersProcessed: actionParams.keySet() | 1626 | parametersProcessed: actionParams.keySet() |
| 1612 | ] | 1627 | ] |
| 1613 | } else if (screenDef) { | 1628 | } else { |
| 1614 | // For actions on SimpleScreens screens, determine service name by convention | 1629 | def foundTransition = resolvedScreenDef.getAllTransitions().find { it.getName() == action } |
| 1615 | // updateProductPrice -> update#mantle.product.ProductPrice | 1630 | |
| 1616 | // createProductPrice -> create#mantle.product.ProductPrice | 1631 | if (foundTransition) { |
| 1617 | // deleteProductPrice -> delete#mantle.product.ProductPrice | 1632 | def serviceName = null |
| 1618 | def actionPrefix = action?.take(6) | 1633 | if (foundTransition.xmlTransition) { |
| 1619 | if (actionPrefix && actionPrefix in ['update', 'create', 'delete']) { | 1634 | def serviceCallNode = foundTransition.xmlTransition.first("service-call") |
| 1620 | def serviceName = "${actionPrefix}#mantle.product.ProductPrice" | 1635 | if (serviceCallNode) serviceName = serviceCallNode.attribute("name") |
| 1621 | ec.logger.info("BrowseScreens: Calling service by convention: ${serviceName} with params: ${actionParams}") | ||
| 1622 | |||
| 1623 | // Call service directly | ||
| 1624 | def serviceCallResult = ec.service.sync().name(serviceName).parameters(actionParams).call() | ||
| 1625 | |||
| 1626 | actionResult = [ | ||
| 1627 | action: action, | ||
| 1628 | status: "executed", | ||
| 1629 | message: "Action '${action}' executed service: ${serviceName}", | ||
| 1630 | service: serviceName, | ||
| 1631 | result: serviceCallResult | ||
| 1632 | ] | ||
| 1633 | } else { | ||
| 1634 | // For other screens or transitions, look for matching transition | ||
| 1635 | def allTransitions = screenDef.getAllTransitions() | ||
| 1636 | if (allTransitions) { | ||
| 1637 | for (def transition : allTransitions) { | ||
| 1638 | if (transition.getName() == action) { | ||
| 1639 | foundTransition = transition | ||
| 1640 | break | ||
| 1641 | } | ||
| 1642 | } | ||
| 1643 | } | 1636 | } |
| 1644 | 1637 | ||
| 1645 | if (foundTransition) { | 1638 | if (serviceName) { |
| 1646 | // Found a transition but it didn't match the CRUD convention | 1639 | ec.logger.info("BrowseScreens: Executing service: ${serviceName}") |
| 1647 | // Try to execute if it has a direct service call | 1640 | def serviceCallResult = ec.service.sync().name(serviceName).parameters(actionParams).call() |
| 1648 | def serviceName = null | 1641 | actionResult = [ |
| 1649 | if (foundTransition.xmlTransition) { | 1642 | action: action, |
| 1650 | // Check for service-call node | 1643 | status: "executed", |
| 1651 | def serviceCallNode = foundTransition.xmlTransition.first("service-call") | 1644 | message: "Executed service ${serviceName}", |
| 1652 | if (serviceCallNode) serviceName = serviceCallNode.attribute("name") | 1645 | result: serviceCallResult |
| 1653 | } | 1646 | ] |
| 1654 | |||
| 1655 | if (serviceName) { | ||
| 1656 | ec.logger.info("BrowseScreens: Executing found transition '${action}' service: ${serviceName}") | ||
| 1657 | def serviceCallResult = ec.service.sync().name(serviceName).parameters(actionParams).call() | ||
| 1658 | actionResult = [ | ||
| 1659 | action: action, | ||
| 1660 | status: "executed", | ||
| 1661 | message: "Executed service ${serviceName}", | ||
| 1662 | result: serviceCallResult | ||
| 1663 | ] | ||
| 1664 | } else { | ||
| 1665 | actionResult = [ | ||
| 1666 | action: action, | ||
| 1667 | status: "success", | ||
| 1668 | message: "Transition '${action}' ready for screen processing (no direct service found)" | ||
| 1669 | ] | ||
| 1670 | } | ||
| 1671 | } else { | 1647 | } else { |
| 1672 | actionResult = [ | 1648 | actionResult = [ |
| 1673 | action: action, | 1649 | action: action, |
| 1674 | status: "not_found", | 1650 | status: "success", |
| 1675 | message: "Transition '${action}' not found on screen ${currentPath}" | 1651 | message: "Transition '${action}' found" |
| 1676 | ] | 1652 | ] |
| 1677 | } | 1653 | } |
| 1654 | } else { | ||
| 1655 | // Fallback: check if it's a CRUD convention action for mantle entities | ||
| 1656 | def actionPrefix = action.size() > 6 ? action.take(6) : "" | ||
| 1657 | if (actionPrefix in ['create', 'update', 'delete']) { | ||
| 1658 | // Try to infer entity from screen context or parameters | ||
| 1659 | actionResult = [status: "error", message: "Dynamic CRUD not implemented without transition"] | ||
| 1660 | } else { | ||
| 1661 | actionError = "Transition '${action}' not found on screen ${currentPath}" | ||
| 1662 | } | ||
| 1678 | } | 1663 | } |
| 1679 | |||
| 1680 | } else { | ||
| 1681 | actionResult = [ | ||
| 1682 | action: action, | ||
| 1683 | status: "not_found", | ||
| 1684 | message: "No screen found or screen has no transitions" | ||
| 1685 | ] | ||
| 1686 | } | 1664 | } |
| 1687 | |||
| 1688 | ec.logger.info("BrowseScreens: Action result: ${actionResult}") | ||
| 1689 | } catch (Exception e) { | 1665 | } catch (Exception e) { |
| 1690 | actionError = "Action execution failed: ${e.message}" | 1666 | actionError = "Action execution failed: ${e.message}" |
| 1691 | ec.logger.warn("BrowseScreens action error for ${currentPath}: ${e.message}") | ||
| 1692 | } | 1667 | } |
| 1693 | } | 1668 | } |
| 1694 | 1669 | ||
| ... | @@ -1704,87 +1679,58 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1704,87 +1679,58 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1704 | def renderError = null | 1679 | def renderError = null |
| 1705 | def actualRenderMode = renderMode ?: "mcp" | 1680 | def actualRenderMode = renderMode ?: "mcp" |
| 1706 | 1681 | ||
| 1682 | def resultMap = [ | ||
| 1683 | currentPath: currentPath, | ||
| 1684 | subscreens: subscreens, | ||
| 1685 | renderMode: actualRenderMode | ||
| 1686 | ] | ||
| 1687 | |||
| 1707 | if (currentPath != "root") { | 1688 | if (currentPath != "root") { |
| 1708 | try { | 1689 | try { |
| 1709 | ec.logger.info("BrowseScreens: Rendering screen ${currentPath} with mode=${actualRenderMode}") | 1690 | ec.logger.info("BrowseScreens: Rendering screen ${currentPath} with mode=${actualRenderMode}") |
| 1710 | 1691 | ||
| 1711 | // Use same resolution logic as browse_screens | 1692 | // Pass forward-slash path directly to ScreenAsMcpTool |
| 1712 | def pathParts = currentPath.split('\\.') | 1693 | // ScreenAsMcpTool will use Moqui's ScreenUrlInfo.parseSubScreenPath to navigate through screen hierarchy |
| 1713 | def componentName = pathParts[0] | 1694 | def browseScreenCallParams = [ |
| 1714 | def screenPath = null | 1695 | path: path, |
| 1715 | def subscreenName = null | 1696 | parameters: parameters ?: [:], |
| 1716 | |||
| 1717 | for (int i = pathParts.size(); i >= 1; i--) { | ||
| 1718 | def subPath = i > 1 ? pathParts[0] + "/" + (pathParts[1..<i].join('/')) : pathParts[0] | ||
| 1719 | def currentTry = "component://${componentName}/screen/${subPath}.xml" | ||
| 1720 | if (ec.resource.getLocationReference(currentTry).getExists()) { | ||
| 1721 | screenPath = currentTry | ||
| 1722 | // If we found a screen matching the full path, we're already at the target | ||
| 1723 | if (i < pathParts.size()) { | ||
| 1724 | def remainingParts = pathParts[i..-1] | ||
| 1725 | subscreenName = remainingParts.size() > 1 ? remainingParts.join('_') : remainingParts[0] | ||
| 1726 | } else { | ||
| 1727 | subscreenName = null | ||
| 1728 | } | ||
| 1729 | break | ||
| 1730 | } | ||
| 1731 | } | ||
| 1732 | |||
| 1733 | if (!screenPath) { | ||
| 1734 | screenPath = "component://${componentName}/screen/${componentName}.xml" | ||
| 1735 | if (pathParts.size() > 1) { | ||
| 1736 | subscreenName = pathParts[1..-1].join('_') | ||
| 1737 | } | ||
| 1738 | } | ||
| 1739 | |||
| 1740 | // Build render parameters | ||
| 1741 | def renderParams = parameters ?: [:] | ||
| 1742 | renderParams.userId = ec.user.userId | ||
| 1743 | renderParams.username = ec.user.username | ||
| 1744 | |||
| 1745 | def screenCallParams = [ | ||
| 1746 | screenPath: screenPath, | ||
| 1747 | parameters: renderParams, | ||
| 1748 | renderMode: actualRenderMode, | 1697 | renderMode: actualRenderMode, |
| 1749 | sessionId: sessionId, | 1698 | sessionId: sessionId, |
| 1750 | terse: context.terse == true | 1699 | terse: context.terse == true |
| 1751 | ] | 1700 | ] |
| 1752 | if (subscreenName) screenCallParams.subscreenName = subscreenName | 1701 | |
| 1753 | |||
| 1754 | // Call ScreenAsMcpTool to render | 1702 | // Call ScreenAsMcpTool to render |
| 1755 | def serviceResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") | 1703 | def browseResult = ec.service.sync().name("McpServices.execute#ScreenAsMcpTool") |
| 1756 | .parameters(screenCallParams) | 1704 | .parameters(browseScreenCallParams) |
| 1757 | .call() | 1705 | .call() |
| 1758 | 1706 | ||
| 1759 | // Extract rendered content and semantic state from result | 1707 | // Extract rendered content and semantic state from result |
| 1760 | if (serviceResult) { | 1708 | if (browseResult) { |
| 1761 | def resultObj = null | 1709 | def resultObj = null |
| 1762 | if (serviceResult.containsKey('content') && serviceResult.content && serviceResult.content.size() > 0) { | 1710 | // ScreenAsMcpTool returns {result: {content: [...]}} |
| 1763 | def rawText = serviceResult.content[0].text | 1711 | if (browseResult.result) { |
| 1764 | if (rawText && rawText.startsWith("{")) { | 1712 | def contentList = browseResult.result.content |
| 1765 | try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {} | 1713 | if (contentList && contentList.size() > 0) { |
| 1766 | } | 1714 | def rawText = contentList[0].text |
| 1767 | renderedContent = rawText | 1715 | if (rawText && rawText.startsWith("{")) { |
| 1768 | } else if (serviceResult.containsKey('result') && serviceResult.result && serviceResult.result.content && serviceResult.result.content.size() > 0) { | 1716 | try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {} |
| 1769 | def rawText = serviceResult.result.content[0].text | 1717 | } |
| 1770 | if (rawText && rawText.startsWith("{")) { | 1718 | renderedContent = rawText |
| 1771 | try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {} | ||
| 1772 | } | 1719 | } |
| 1773 | renderedContent = rawText | ||
| 1774 | } | 1720 | } |
| 1775 | 1721 | ||
| 1776 | if (resultObj && resultObj.semanticState) { | 1722 | if (resultObj && resultObj.semanticState) { |
| 1777 | resultMap.semanticState = resultObj.semanticState | 1723 | resultMap.semanticState = resultObj.semanticState |
| 1778 | 1724 | ||
| 1779 | // Build UI narrative for LLM guidance | 1725 | // Build UI narrative for LLM guidance |
| 1780 | try { | 1726 | try { |
| 1781 | def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder() | 1727 | def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder() |
| 1782 | // Get screen definition for narrative building | 1728 | // Get screen definition for narrative building |
| 1783 | def screenDefForNarrative = null | 1729 | def screenDefForNarrative = null |
| 1784 | if (screenPath) { | 1730 | if (resultObj.semanticState.screenPath) { |
| 1785 | screenDefForNarrative = ec.screen.getScreenDefinition(screenPath) | 1731 | screenDefForNarrative = ec.screen.getScreenDefinition(resultObj.semanticState.screenPath) |
| 1786 | } | 1732 | } |
| 1787 | 1733 | ||
| 1788 | def uiNarrative = narrativeBuilder.buildNarrative( | 1734 | def uiNarrative = narrativeBuilder.buildNarrative( |
| 1789 | screenDefForNarrative, | 1735 | screenDefForNarrative, |
| 1790 | resultObj.semanticState, | 1736 | resultObj.semanticState, |
| ... | @@ -1796,14 +1742,14 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1796,14 +1742,14 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1796 | } catch (Exception e) { | 1742 | } catch (Exception e) { |
| 1797 | ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}") | 1743 | ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}") |
| 1798 | } | 1744 | } |
| 1799 | 1745 | ||
| 1800 | // If we have semantic state, we can truncate the rendered content to save tokens | 1746 | // If we have semantic state, we can truncate rendered content to save tokens |
| 1801 | if (renderedContent && renderedContent.length() > 500) { | 1747 | if (renderedContent && renderedContent.length() > 500) { |
| 1802 | renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)" | 1748 | renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)" |
| 1803 | } | 1749 | } |
| 1804 | } | 1750 | } |
| 1805 | } | 1751 | } |
| 1806 | 1752 | ||
| 1807 | ec.logger.info("BrowseScreens: Successfully rendered screen ${currentPath}, content length: ${renderedContent?.length() ?: 0}") | 1753 | ec.logger.info("BrowseScreens: Successfully rendered screen ${currentPath}, content length: ${renderedContent?.length() ?: 0}") |
| 1808 | } catch (Exception e) { | 1754 | } catch (Exception e) { |
| 1809 | renderError = "Screen rendering failed: ${e.message}" | 1755 | renderError = "Screen rendering failed: ${e.message}" |
| ... | @@ -1811,13 +1757,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1811,13 +1757,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1811 | } | 1757 | } |
| 1812 | } | 1758 | } |
| 1813 | 1759 | ||
| 1814 | // Build result - return in MCP format with content array | ||
| 1815 | def resultMap = [ | ||
| 1816 | currentPath: currentPath, | ||
| 1817 | subscreens: subscreens, | ||
| 1818 | renderMode: actualRenderMode | ||
| 1819 | ] | ||
| 1820 | |||
| 1821 | if (actionResult) { | 1760 | if (actionResult) { |
| 1822 | resultMap.actionResult = actionResult | 1761 | resultMap.actionResult = actionResult |
| 1823 | } | 1762 | } |
| ... | @@ -1830,6 +1769,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1830,6 +1769,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1830 | resultMap.renderedContent = renderedContent | 1769 | resultMap.renderedContent = renderedContent |
| 1831 | } | 1770 | } |
| 1832 | 1771 | ||
| 1772 | // Handle empty content case | ||
| 1773 | if (!renderedContent && !renderError) { | ||
| 1774 | resultMap.renderedContent = "SCREEN_RENDERED_EMPTY: No output generated (screen may be waiting for parameters or has no content)" | ||
| 1775 | } | ||
| 1776 | |||
| 1833 | if (wikiInstructions) { | 1777 | if (wikiInstructions) { |
| 1834 | resultMap.wikiInstructions = wikiInstructions | 1778 | resultMap.wikiInstructions = wikiInstructions |
| 1835 | } | 1779 | } |
| ... | @@ -1863,12 +1807,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1863,12 +1807,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1863 | ExecutionContext ec = context.ec | 1807 | ExecutionContext ec = context.ec |
| 1864 | def matches = [] | 1808 | def matches = [] |
| 1865 | 1809 | ||
| 1866 | // Strip query parameters from path if present | 1810 | // Helper to convert full component path to simple forward-slash path |
| 1867 | if (query.contains("?")) { | ||
| 1868 | query = query.split("\\?")[0] | ||
| 1869 | } | ||
| 1870 | |||
| 1871 | // Helper to convert full component path to simple path | ||
| 1872 | def convertToSimplePath = { fullPath -> | 1811 | def convertToSimplePath = { fullPath -> |
| 1873 | if (!fullPath) return null | 1812 | if (!fullPath) return null |
| 1874 | String cleanPath = fullPath | 1813 | String cleanPath = fullPath |
| ... | @@ -1876,7 +1815,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1876,7 +1815,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1876 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) | 1815 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) |
| 1877 | List<String> parts = cleanPath.split('/').toList() | 1816 | List<String> parts = cleanPath.split('/').toList() |
| 1878 | if (parts.size() > 1 && parts[1] == "screen") parts.remove(1) | 1817 | if (parts.size() > 1 && parts[1] == "screen") parts.remove(1) |
| 1879 | return parts.join('.') | 1818 | return parts.join('/') |
| 1880 | } | 1819 | } |
| 1881 | 1820 | ||
| 1882 | // Search all screens known to the system | 1821 | // Search all screens known to the system |
| ... | @@ -1904,137 +1843,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1904,137 +1843,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1904 | </actions> | 1843 | </actions> |
| 1905 | </service> | 1844 | </service> |
| 1906 | 1845 | ||
| 1907 | <service verb="mcp" noun="GetScreenDetails" authenticate="false" allow-remote="true" transaction-timeout="30"> | ||
| 1908 | <description>Get detailed schema and usage info for a specific screen path.</description> | ||
| 1909 | <in-parameters> | ||
| 1910 | <parameter name="path" required="true"><description>Screen path (e.g. PopCommerce.Catalog)</description></parameter> | ||
| 1911 | <parameter name="sessionId"/> | ||
| 1912 | </in-parameters> | ||
| 1913 | <out-parameters> | ||
| 1914 | <parameter name="result" type="Map"/> | ||
| 1915 | </out-parameters> | ||
| 1916 | <actions> | ||
| 1917 | <script><![CDATA[ | ||
| 1918 | import org.moqui.context.ExecutionContext | ||
| 1919 | |||
| 1920 | ExecutionContext ec = context.ec | ||
| 1921 | |||
| 1922 | // Strip query parameters from path if present | ||
| 1923 | if (path.contains("?")) { | ||
| 1924 | path = path.split("\\?")[0] | ||
| 1925 | } | ||
| 1926 | |||
| 1927 | // Resolve simple path to component path using longest match and traversal | ||
| 1928 | def pathParts = path.split('\\.') | ||
| 1929 | def componentName = pathParts[0] | ||
| 1930 | def baseScreenPath = null | ||
| 1931 | def subParts = [] | ||
| 1932 | |||
| 1933 | for (int i = pathParts.size(); i >= 1; i--) { | ||
| 1934 | def subPath = i > 1 ? pathParts[0] + "/" + (pathParts[1..<i].join('/')) : pathParts[0] | ||
| 1935 | def currentTry = "component://${componentName}/screen/${subPath}.xml" | ||
| 1936 | if (ec.resource.getLocationReference(currentTry).getExists()) { | ||
| 1937 | baseScreenPath = currentTry | ||
| 1938 | if (i < pathParts.size()) subParts = pathParts[i..-1] | ||
| 1939 | break | ||
| 1940 | } | ||
| 1941 | } | ||
| 1942 | |||
| 1943 | if (!baseScreenPath) { | ||
| 1944 | baseScreenPath = "component://${componentName}/screen/${componentName}.xml" | ||
| 1945 | if (pathParts.size() > 1) subParts = pathParts[1..-1] | ||
| 1946 | } | ||
| 1947 | |||
| 1948 | def toolDef = null | ||
| 1949 | |||
| 1950 | if (ec.resource.getLocationReference(baseScreenPath).getExists()) { | ||
| 1951 | try { | ||
| 1952 | def screenDef = ec.screen.getScreenDefinition(baseScreenPath) | ||
| 1953 | |||
| 1954 | // Traverse to final subscreen | ||
| 1955 | for (subName in subParts) { | ||
| 1956 | def subItem = screenDef?.getSubscreensItem(subName) | ||
| 1957 | if (subItem && subItem.getLocation()) { | ||
| 1958 | screenDef = ec.screen.getScreenDefinition(subItem.getLocation()) | ||
| 1959 | } else { | ||
| 1960 | break | ||
| 1961 | } | ||
| 1962 | } | ||
| 1963 | |||
| 1964 | if (screenDef && screenDef.screenNode) { | ||
| 1965 | def properties = [:] | ||
| 1966 | def required = [] | ||
| 1967 | def getJsonType = { moquiType -> | ||
| 1968 | def typeRes = ec.service.sync().name("McpServices.convert#MoquiTypeToJsonSchemaType") | ||
| 1969 | .parameter("moquiType", moquiType).call() | ||
| 1970 | return typeRes?.jsonSchemaType ?: "string" | ||
| 1971 | } | ||
| 1972 | |||
| 1973 | } | ||
| 1974 | |||
| 1975 | if (screenDef) { | ||
| 1976 | def properties = [:] | ||
| 1977 | def required = [] | ||
| 1978 | def getJsonType = { moquiType -> | ||
| 1979 | def typeRes = ec.service.sync().name("McpServices.convert#MoquiTypeToJsonSchemaType") | ||
| 1980 | .parameter("moquiType", moquiType).call() | ||
| 1981 | return typeRes?.jsonSchemaType ?: "string" | ||
| 1982 | } | ||
| 1983 | |||
| 1984 | // Extract parameters from screen definition (using protected field access in Groovy) | ||
| 1985 | if (screenDef.parameterByName) { | ||
| 1986 | screenDef.parameterByName.each { name, param -> | ||
| 1987 | properties[name] = [type: "string", description: "Screen Parameter"] | ||
| 1988 | } | ||
| 1989 | } | ||
| 1990 | |||
| 1991 | // Try to get forms and their entities | ||
| 1992 | if (screenDef.formByName) { | ||
| 1993 | screenDef.formByName.each { name, form -> | ||
| 1994 | def formNode = form.internalFormNode | ||
| 1995 | if (!formNode) return | ||
| 1996 | |||
| 1997 | def entityName = formNode.attribute("entity-name") | ||
| 1998 | if (!entityName) { | ||
| 1999 | def entityFind = formNode.first("entity-find") | ||
| 2000 | if (entityFind) entityName = entityFind.attribute("entity-name") | ||
| 2001 | } | ||
| 2002 | |||
| 2003 | if (entityName && ec.entity.isEntityDefined(entityName)) { | ||
| 2004 | def entityDef = ec.entity.getEntityDefinition(entityName) | ||
| 2005 | entityDef.getAllFieldNames().each { fieldName -> | ||
| 2006 | if (!properties[fieldName]) { | ||
| 2007 | def fieldInfo = entityDef.getFieldNode(fieldName) | ||
| 2008 | properties[fieldName] = [ | ||
| 2009 | type: getJsonType(fieldInfo.attribute("type")), | ||
| 2010 | description: "Inferred from entity ${entityName} (form ${name})" | ||
| 2011 | ] | ||
| 2012 | } | ||
| 2013 | } | ||
| 2014 | } | ||
| 2015 | } | ||
| 2016 | } | ||
| 2017 | |||
| 2018 | toolDef = [ | ||
| 2019 | path: path, | ||
| 2020 | description: "Details for screen ${path}", | ||
| 2021 | inputSchema: [ | ||
| 2022 | type: "object", | ||
| 2023 | properties: properties, | ||
| 2024 | required: required | ||
| 2025 | ] | ||
| 2026 | ] | ||
| 2027 | } | ||
| 2028 | } catch (Exception e) { | ||
| 2029 | ec.logger.warn("Error getting screen details for ${path}: ${e.message}") | ||
| 2030 | } | ||
| 2031 | } | ||
| 2032 | |||
| 2033 | result = [details: toolDef] | ||
| 2034 | ]]></script> | ||
| 2035 | </actions> | ||
| 2036 | </service> | ||
| 2037 | |||
| 2038 | <service verb="list" noun="Tools" authenticate="false" allow-remote="true" transaction-timeout="60"> | 1846 | <service verb="list" noun="Tools" authenticate="false" allow-remote="true" transaction-timeout="60"> |
| 2039 | <description>List discovery tools and the unified screen renderer.</description> | 1847 | <description>List discovery tools and the unified screen renderer.</description> |
| 2040 | <in-parameters> | 1848 | <in-parameters> |
| ... | @@ -2052,21 +1860,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -2052,21 +1860,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 2052 | 1860 | ||
| 2053 | def tools = [ | 1861 | def tools = [ |
| 2054 | [ | 1862 | [ |
| 2055 | name: "moqui_render_screen", | ||
| 2056 | title: "Render Screen", | ||
| 2057 | description: "Execute and render a Moqui screen. Use discovery tools to find paths.", | ||
| 2058 | inputSchema: [ | ||
| 2059 | type: "object", | ||
| 2060 | properties: [ | ||
| 2061 | "path": [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], | ||
| 2062 | "parameters": [type: "object", description: "Parameters for the screen"], | ||
| 2063 | "renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"], | ||
| 2064 | "terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"] | ||
| 2065 | ], | ||
| 2066 | required: ["path"] | ||
| 2067 | ] | ||
| 2068 | ], | ||
| 2069 | [ | ||
| 2070 | name: "moqui_browse_screens", | 1863 | name: "moqui_browse_screens", |
| 2071 | title: "Browse Screens", | 1864 | title: "Browse Screens", |
| 2072 | description: "Browse Moqui screen hierarchy, process actions, and render screen content. Input 'path' (empty for root). Default renderMode is 'mcp'.", | 1865 | description: "Browse Moqui screen hierarchy, process actions, and render screen content. Input 'path' (empty for root). Default renderMode is 'mcp'.", |
| ... | @@ -2092,20 +1885,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -2092,20 +1885,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 2092 | ], | 1885 | ], |
| 2093 | required: ["query"] | 1886 | required: ["query"] |
| 2094 | ] | 1887 | ] |
| 2095 | ], | 1888 | ], |
| 2096 | [ | 1889 | [ |
| 2097 | name: "moqui_get_screen_details", | ||
| 2098 | title: "Get Screen Details", | ||
| 2099 | description: "Get detailed schema for a specific screen path.", | ||
| 2100 | inputSchema: [ | ||
| 2101 | type: "object", | ||
| 2102 | properties: [ | ||
| 2103 | "path": [type: "string", description: "Screen path"] | ||
| 2104 | ], | ||
| 2105 | required: ["path"] | ||
| 2106 | ] | ||
| 2107 | ], | ||
| 2108 | [ | ||
| 2109 | name: "prompts_list", | 1890 | name: "prompts_list", |
| 2110 | title: "List Prompts", | 1891 | title: "List Prompts", |
| 2111 | description: "List available MCP prompt templates.", | 1892 | description: "List available MCP prompt templates.", | ... | ... |
| ... | @@ -93,13 +93,26 @@ class CustomScreenTestImpl implements McpScreenTest { | ... | @@ -93,13 +93,26 @@ class CustomScreenTestImpl implements McpScreenTest { |
| 93 | return this | 93 | return this |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | protected static List<String> pathToList(String path) { | ||
| 97 | List<String> pathList = new ArrayList<>() | ||
| 98 | if (path && path.contains('/')) { | ||
| 99 | String[] pathSegments = path.split('/') | ||
| 100 | for (String segment in pathSegments) { | ||
| 101 | if (segment && segment.trim().length() > 0) { | ||
| 102 | pathList.add(segment) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | } | ||
| 106 | return pathList | ||
| 107 | } | ||
| 108 | |||
| 96 | @Override | 109 | @Override |
| 97 | McpScreenTest baseScreenPath(String screenPath) { | 110 | McpScreenTest baseScreenPath(String screenPath) { |
| 98 | if (!rootScreenLocation) throw new BaseArtifactException("No rootScreen specified") | 111 | if (!rootScreenLocation) throw new BaseArtifactException("No rootScreen specified") |
| 99 | baseScreenPath = screenPath | 112 | baseScreenPath = screenPath |
| 100 | if (baseScreenPath.endsWith("/")) baseScreenPath = baseScreenPath.substring(0, baseScreenPath.length() - 1) | 113 | if (baseScreenPath.endsWith("/")) baseScreenPath = baseScreenPath.substring(0, baseScreenPath.length() - 1) |
| 101 | if (baseScreenPath) { | 114 | if (baseScreenPath) { |
| 102 | baseScreenPathList = ScreenUrlInfo.parseSubScreenPath(rootScreenDef, rootScreenDef, [], baseScreenPath, null, sfi) | 115 | baseScreenPathList = ScreenUrlInfo.parseSubScreenPath(rootScreenDef, rootScreenDef, pathToList(baseScreenPath), baseScreenPath, [:], sfi) |
| 103 | if (baseScreenPathList == null) throw new BaseArtifactException("Error in baseScreenPath, could find not base screen path ${baseScreenPath} under ${rootScreenDef.location}") | 116 | if (baseScreenPathList == null) throw new BaseArtifactException("Error in baseScreenPath, could find not base screen path ${baseScreenPath} under ${rootScreenDef.location}") |
| 104 | for (String screenName in baseScreenPathList) { | 117 | for (String screenName in baseScreenPathList) { |
| 105 | ScreenDefinition.SubscreensItem ssi = baseScreenDef.getSubscreensItem(screenName) | 118 | ScreenDefinition.SubscreensItem ssi = baseScreenDef.getSubscreensItem(screenName) |
| ... | @@ -282,10 +295,24 @@ class CustomScreenTestImpl implements McpScreenTest { | ... | @@ -282,10 +295,24 @@ class CustomScreenTestImpl implements McpScreenTest { |
| 282 | } | 295 | } |
| 283 | } | 296 | } |
| 284 | logger.info("Custom screen path parsing for non-webroot root: ${screenPathList}") | 297 | logger.info("Custom screen path parsing for non-webroot root: ${screenPathList}") |
| 285 | } else { | 298 | } else { |
| 286 | screenPathList = ScreenUrlInfo.parseSubScreenPath(csti.rootScreenDef, csti.baseScreenDef, | 299 | // For webroot or other cases, use ScreenUrlInfo.parseSubScreenPath for resolution |
| 287 | csti.baseScreenPathList, stri.screenPath, stri.parameters, csti.sfi) | 300 | // Convert screenPath to list for parseSubScreenPath |
| 288 | } | 301 | List<String> inputPathList = new ArrayList<>() |
| 302 | if (stri.screenPath && stri.screenPath.contains('/')) { | ||
| 303 | String[] pathSegments = stri.screenPath.split('/') | ||
| 304 | for (String segment in pathSegments) { | ||
| 305 | if (segment && segment.trim().length() > 0) { | ||
| 306 | inputPathList.add(segment) | ||
| 307 | } | ||
| 308 | } | ||
| 309 | } | ||
| 310 | |||
| 311 | // Use Moqui's parseSubScreenPath to resolve actual screen path | ||
| 312 | // Note: pass null for fromPathList since stri.screenPath is already relative to root or from screen | ||
| 313 | screenPathList = ScreenUrlInfo.parseSubScreenPath(csti.rootScreenDef, csti.baseScreenDef, | ||
| 314 | null, stri.screenPath, stri.parameters, csti.sfi) | ||
| 315 | } | ||
| 289 | if (screenPathList == null) throw new BaseArtifactException("Could not find screen path ${stri.screenPath} under base screen ${csti.baseScreenDef.location}") | 316 | if (screenPathList == null) throw new BaseArtifactException("Could not find screen path ${stri.screenPath} under base screen ${csti.baseScreenDef.location}") |
| 290 | 317 | ||
| 291 | // push the context | 318 | // push the context | ... | ... |
-
Please register or sign in to post a comment