Fix action execution - resolve screen path and traverse default subscreens
Critical fixes: - Set resolvedScreenDef after successful literal path resolution (was falling back to webroot) - Traverse default subscreens when looking up transitions (matching action discovery logic) - Build full path including subscreens for framework transition execution New features: - moqui_batch_operations tool for multi-step workflows - Enhanced validation error handling with field-level details - ARIA mode now includes value, ref, description, describedby attributes Documentation: - Added render modes table and examples to AGENTS.md - Updated README with MARIA format explanation - Added MCP_SERVICE_DOCS wiki space with service parameter docs
Showing
5 changed files
with
1450 additions
and
134 deletions
| 1 | # Moqui MCP: AI in the Corporate Cockpit | 1 | # Moqui MCP: AI Agents in the Enterprise |
| 2 | 2 | ||
| 3 | **⚠️ WARNING: THIS DOG MAY EAT YOUR HOMEWORK! ⚠️** | 3 | **Give AI agents real jobs in real business systems.** |
| 4 | 4 | ||
| 5 | This system can give AI agents god-mode access to an entire ERP system - that's not a feature, it's a loaded weapon requiring serious security consideration. | 5 | [](https://www.youtube.com/watch?v=Tauucda-NV4) |
| 6 | 6 | ||
| 7 | ## 🎥 **SEE IT WORK** | 7 | ## What This Is |
| 8 | 8 | ||
| 9 | [](https://www.youtube.com/watch?v=Tauucda-NV4) | 9 | Moqui MCP connects AI agents to [Moqui Framework](https://www.moqui.org/), an open-source ERP platform. Through MCP (Model Context Protocol), agents can browse screens, fill forms, execute transactions, and query data - the same operations humans perform through the web interface. |
| 10 | |||
| 11 | **This isn't a chatbot bolted onto an ERP. It's AI with direct access to business operations.** | ||
| 10 | 12 | ||
| 11 | **AI agents running real business operations.** | 13 | ## What Agents Can Do |
| 12 | 14 | ||
| 13 | --- | 15 | - **Browse** the complete application hierarchy - catalog, orders, parties, accounting |
| 16 | - **Search** products, customers, inventory with full query capabilities | ||
| 17 | - **Create** orders, invoices, shipments, parties, products | ||
| 18 | - **Update** prices, quantities, statuses, relationships | ||
| 19 | - **Execute** workflows spanning multiple screens and services | ||
| 14 | 20 | ||
| 15 | ## 🚀 **THE POSSIBILITIES** | 21 | All operations respect Moqui's security model. Agents see only what their user account permits. |
| 22 | |||
| 23 | ## Why Moqui? | ||
| 24 | |||
| 25 | ERP systems are the operational backbone of business. They contain: | ||
| 26 | |||
| 27 | - **Real data**: Actual inventory levels, customer records, financial transactions | ||
| 28 | - **Real processes**: Order-to-cash, procure-to-pay, hire-to-retire workflows | ||
| 29 | - **Real constraints**: Business rules, approval chains, compliance requirements | ||
| 30 | |||
| 31 | Moqui provides all of this as open-source software with a uniquely AI-friendly architecture: | ||
| 16 | 32 | ||
| 17 | ### **Autonomous Business Operations** | 33 | - **Declarative screens**: XML definitions with rich semantic metadata |
| 18 | - **AI Purchasing Agents**: Negotiate with suppliers using real inventory data | 34 | - **Service-oriented**: Clean separation between UI and business logic |
| 19 | - **Dynamic Pricing**: Adjust prices based on live demand and supply constraints | 35 | - **Artifact security**: Fine-grained permissions on every screen, service, and entity |
| 20 | - **Workforce Intelligence**: Optimize scheduling with real financial modeling | 36 | - **Extensible**: Add AI-specific screens and services without forking |
| 21 | - **Supply Chain Orchestration**: Coordinate global logistics automatically | 37 | |
| 38 | ## The MARIA Format | ||
| 22 | 39 | ||
| 23 | ### **Real-World Intelligence** | 40 | Enterprise screens are built for humans with visual context. AI agents need structured semantics. We solved this with **MARIA (MCP Accessible Rich Internet Applications)** - a JSON format based on W3C accessibility standards. |
| 24 | - **Market Analysis**: AI sees actual sales data, not just trends | ||
| 25 | - **Financial Forecasting**: Ground predictions in real business metrics | ||
| 26 | - **Risk Management**: Monitor operations for anomalies and opportunities | ||
| 27 | - **Compliance Automation**: Enforce business rules across all processes | ||
| 28 | 41 | ||
| 29 | ### **The Agentic Economy** | 42 | MARIA transforms Moqui screens into accessibility trees that LLMs naturally understand: |
| 30 | - **Multi-Agent Systems**: Sales, purchasing, operations AI working together | ||
| 31 | - **ECA/SECA Integration**: Event-driven autonomous decision making | ||
| 32 | - **Cross-Company Coordination**: AI agents negotiating with other AI agents | ||
| 33 | - **Economic Simulation**: Test strategies in real business environment | ||
| 34 | 43 | ||
| 35 | ERP systems naturally create the perfect **agentic theater** - casting distinct roles around HR, Sales, Production, Accounting, and more. This organizational structure provides a powerful grounding framework where AI agents can inhabit authentic business personas with clear responsibilities, workflows, and relationships. The departmental boundaries and process flows that govern human collaboration become the stage directions for autonomous agents, turning corporate hierarchy into a narrative engine for AI coordination. | 44 | ```json |
| 45 | { | ||
| 46 | "role": "document", | ||
| 47 | "name": "FindParty", | ||
| 48 | "children": [ | ||
| 49 | { | ||
| 50 | "role": "form", | ||
| 51 | "name": "CreatePersonForm", | ||
| 52 | "children": [ | ||
| 53 | {"role": "textbox", "name": "First Name", "required": true}, | ||
| 54 | {"role": "textbox", "name": "Last Name", "required": true}, | ||
| 55 | {"role": "combobox", "name": "Role", "options": 140}, | ||
| 56 | {"role": "button", "name": "createPerson"} | ||
| 57 | ] | ||
| 58 | }, | ||
| 59 | { | ||
| 60 | "role": "grid", | ||
| 61 | "name": "PartyListForm", | ||
| 62 | "rowcount": 47, | ||
| 63 | "columns": ["ID", "Name", "Username", "Role"], | ||
| 64 | "children": [ | ||
| 65 | {"role": "row", "name": "John Sales"}, | ||
| 66 | {"role": "row", "name": "Jane Accountant"} | ||
| 67 | ], | ||
| 68 | "moreRows": 45 | ||
| 69 | } | ||
| 70 | ] | ||
| 71 | } | ||
| 72 | ``` | ||
| 36 | 73 | ||
| 37 | **Every product you touch passed through an inventory system. Now AI touches back.** | 74 | The insight: **AI agents are a new kind of accessibility-challenged user.** They can't see pixels or interpret visual layout - they need structured semantics. This is exactly the problem ARIA solved for screen readers decades ago. But ARIA has no JSON serialization, and screen readers access accessibility trees via local OS APIs, not network protocols. MARIA fills this gap: the ARIA vocabulary, serialized as JSON, transported over MCP. |
| 38 | 75 | ||
| 39 | --- | 76 | Because humans and agents interact through the same semantic model, they can explain actions to each other in the same terms. "I selected 'Shipped' from the Order Status dropdown" means the same thing whether a human or an agent did it - no translation layer needed. |
| 40 | 77 | ||
| 41 | ### 💬 **From the Maintainer** | 78 | ### Why Integrated MARIA Beats Browser Automation |
| 42 | 79 | ||
| 43 | > *"About 50% of this is slop. Ideas for JobSandbox integration?"* | 80 | Tools like Playwright MCP let agents control browsers by capturing screenshots and accessibility snapshots. This works, but it's the wrong abstraction for enterprise systems: |
| 44 | 81 | ||
| 45 | **Your input shapes the roadmap.** | 82 | | Aspect | Playwright/Browser | Integrated MARIA | |
| 83 | |--------|-------------------|------------------| | ||
| 84 | | **Latency** | Screenshot → Vision model → Action | Direct JSON-RPC round-trip | | ||
| 85 | | **Token cost** | Images + DOM snapshots burn tokens | Semantic-only payload | | ||
| 86 | | **State access** | Limited to visible DOM | Full server-side context | | ||
| 87 | | **Security** | Browser session = full UI access | Fine-grained artifact authorization | | ||
| 88 | | **Reliability** | CSS changes break selectors | Stable semantic contracts | | ||
| 89 | | **Batch operations** | One click at a time | Bulk actions in single call | | ||
| 46 | 90 | ||
| 47 | --- | 91 | MARIA delivers the accessibility tree directly from the source - no browser rendering, no vision model, no DOM scraping. The agent gets exactly the semantic structure it needs with none of the overhead. |
| 48 | 92 | ||
| 93 | ### Render Modes | ||
| 49 | 94 | ||
| 95 | | Mode | Output | Use Case | | ||
| 96 | |------|--------|----------| | ||
| 97 | | `aria` | MARIA accessibility tree | Structured agent interaction | | ||
| 98 | | `compact` | Condensed JSON summary | Quick screen overview | | ||
| 99 | | `mcp` | Full semantic state | Complete metadata access | | ||
| 100 | | `text` | Plain text | Simple queries | | ||
| 101 | | `html` | Standard HTML | Debugging, human review | | ||
| 50 | 102 | ||
| 51 | ## Overview | 103 | ## Example Session |
| 52 | 104 | ||
| 53 | This implementation provides the **foundational bridge** between AI assistants and real-world business operations through Moqui ERP. It exposes the complete corporate operating system - screens, services, entities, workflows, and business rules - as MCP tools with **recursive discovery** to arbitrary depth. | 105 | ``` |
| 106 | Agent: moqui_browse_screens(path="PopCommerce/PopCommerceAdmin/Catalog/Product/FindProduct") | ||
| 54 | 107 | ||
| 55 | **Think of this as giving AI agents actual jobs in real companies, with real responsibilities, real consequences, and real accountability.** | 108 | Server: { |
| 109 | "summary": "20 products. Forms: NewProductForm. Actions: createProduct", | ||
| 110 | "grids": {"ProductsForm": {"rowCount": 20, "columns": ["ID", "Name", "Type"]}}, | ||
| 111 | "actions": {"createProduct": {"service": "create#mantle.product.Product"}} | ||
| 112 | } | ||
| 113 | |||
| 114 | Agent: I need to create a new product with variants. | ||
| 115 | moqui_browse_screens( | ||
| 116 | path="PopCommerce/PopCommerceAdmin/Catalog/Product/FindProduct", | ||
| 117 | action="createProduct", | ||
| 118 | parameters={"productName": "Widget Pro", "productTypeEnumId": "PtVirtual"} | ||
| 119 | ) | ||
| 120 | |||
| 121 | Server: { | ||
| 122 | "result": {"status": "executed", "productId": "100042"}, | ||
| 123 | "summary": "Product created. Navigate to EditProduct to add features." | ||
| 124 | } | ||
| 125 | ``` | ||
| 126 | |||
| 127 | ## Getting Started | ||
| 128 | |||
| 129 | ```bash | ||
| 130 | # Clone with submodules | ||
| 131 | git clone --recursive https://github.com/moqui/moqui-mcp | ||
| 132 | |||
| 133 | # Build and load demo data | ||
| 134 | ./gradlew load | ||
| 135 | |||
| 136 | # Start server | ||
| 137 | ./gradlew run | ||
| 138 | |||
| 139 | # MCP endpoint: http://localhost:8080/mcp | ||
| 140 | ``` | ||
| 141 | |||
| 142 | ### MCP Tools | ||
| 143 | |||
| 144 | | Tool | Purpose | | ||
| 145 | |------|---------| | ||
| 146 | | `moqui_browse_screens` | Navigate screens, execute actions, render content | | ||
| 147 | | `moqui_search_screens` | Find screens by name | | ||
| 148 | | `moqui_get_screen_details` | Get field metadata, dropdown options | | ||
| 56 | 149 | ||
| 57 | ## Architecture | 150 | ## Architecture |
| 58 | 151 | ||
| 59 | The implementation consists of: | 152 | ``` |
| 153 | ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ | ||
| 154 | │ AI Agent │────▶│ MCP Servlet │────▶│ Moqui Screen │ | ||
| 155 | │ │◀────│ (JSON-RPC 2.0) │◀────│ Framework │ | ||
| 156 | └─────────────────┘ └──────────────────┘ └─────────────────┘ | ||
| 157 | │ | ||
| 158 | ┌───────┴───────┐ | ||
| 159 | ▼ ▼ | ||
| 160 | ┌──────────────┐ ┌──────────────┐ | ||
| 161 | │ MARIA │ │ Wiki Docs │ | ||
| 162 | │ Transform │ │ (Guidance) │ | ||
| 163 | └──────────────┘ └──────────────┘ | ||
| 164 | ``` | ||
| 60 | 165 | ||
| 61 | - **EnhancedMcpServlet** - Main MCP servlet handling JSON-RPC 2.0 protocol | 166 | - **MCP Servlet**: JSON-RPC 2.0 protocol, session management, authentication |
| 62 | - **McpServices** - Core services for initialization, tool discovery, and execution | 167 | - **Screen Framework**: Moqui's rendering engine with MCP output mode |
| 63 | - **Screen Discovery** - Recursive screen traversal with XML parsing | 168 | - **MARIA Transform**: Converts semantic state to accessibility tree format |
| 64 | - **Security Integration** - Moqui artifact authorization system | 169 | - **Wiki Docs**: Screen-specific instructions with path inheritance |
| 65 | - **Test Suite** - Java/Groovy tests (help please!) | ||
| 66 | 170 | ||
| 171 | ## Use Cases | ||
| 67 | 172 | ||
| 173 | ### Autonomous Operations | ||
| 174 | - Purchasing agents negotiating with supplier catalogs | ||
| 175 | - Inventory agents reordering based on demand forecasts | ||
| 176 | - Pricing agents adjusting margins in real-time | ||
| 68 | 177 | ||
| 69 | ## License | 178 | ### Assisted Workflows |
| 179 | - Customer service agents with full order history access | ||
| 180 | - Sales agents generating quotes from live pricing | ||
| 181 | - Warehouse agents coordinating picks and shipments | ||
| 182 | |||
| 183 | ### Analysis & Reporting | ||
| 184 | - Financial agents querying actuals vs. budgets | ||
| 185 | - Operations agents identifying bottlenecks | ||
| 186 | - Compliance agents auditing transaction trails | ||
| 187 | |||
| 188 | ## Security | ||
| 70 | 189 | ||
| 71 | This project is in the public domain under CC0 1.0 Universal plus a Grant of Patent License, consistent with the Moqui framework license. | 190 | Production deployments should: |
| 72 | 191 | ||
| 192 | - Create dedicated service accounts for AI agents | ||
| 193 | - Use Moqui artifact authorization to limit permissions | ||
| 194 | - Enable comprehensive audit logging | ||
| 195 | - Consider human-in-the-loop for sensitive operations | ||
| 196 | - Start with read-only access, expand incrementally | ||
| 73 | 197 | ||
| 198 | ## Status | ||
| 74 | 199 | ||
| 75 | ## Related Projects | 200 | This is an active proof-of-concept. Working: |
| 76 | 201 | ||
| 77 | - **Moqui Framework** - https://github.com/moqui/moqui-framework | 202 | - Screen browsing and discovery |
| 78 | - **PopCommerce** - https://github.com/moqui/PopCommerce | 203 | - Form submission and action execution |
| 79 | - **MCP Specification** - https://modelcontextprotocol.io/ | 204 | - MARIA/compact/MCP render modes |
| 205 | - Wiki documentation with inheritance | ||
| 206 | - Artifact security integration | ||
| 80 | 207 | ||
| 208 | Roadmap: | ||
| 81 | 209 | ||
| 210 | - Entity-level queries (beyond screen context) | ||
| 211 | - Service direct invocation | ||
| 212 | - Real-time notifications (ARIA live regions) | ||
| 213 | - Multi-agent coordination patterns | ||
| 214 | |||
| 215 | ## Contributing | ||
| 216 | |||
| 217 | Contributions welcome: | ||
| 218 | |||
| 219 | - Test coverage and edge cases | ||
| 220 | - Additional ARIA role mappings | ||
| 221 | - Performance optimization | ||
| 222 | - Documentation and examples | ||
| 223 | - Integration patterns for other MCP clients | ||
| 224 | |||
| 225 | ## License | ||
| 82 | 226 | ||
| 83 | ## Support | 227 | Public domain under CC0 1.0 Universal plus Grant of Patent License, consistent with Moqui Framework. |
| 84 | 228 | ||
| 85 | For issues and questions: | 229 | ## Links |
| 86 | 230 | ||
| 87 | 1. [Moqui Forum Discussion](https://forum.moqui.org/t/poc-mcp-server-as-a-moqui-component) | 231 | - [Moqui Framework](https://github.com/moqui/moqui-framework) |
| 88 | 2. Review test examples in `test/` | 232 | - [MCP Specification](https://modelcontextprotocol.io/) |
| 89 | 3. Consult Moqui documentation | 233 | - [W3C ARIA](https://www.w3.org/WAI/ARIA/apg/) |
| 90 | 4. Check server logs for detailed error information | 234 | - [Forum Discussion](https://forum.moqui.org/t/poc-mcp-server-as-a-moqui-component) | ... | ... |
| ... | @@ -24,7 +24,7 @@ | ... | @@ -24,7 +24,7 @@ |
| 24 | <!-- WikiSpace Root Page --> | 24 | <!-- WikiSpace Root Page --> |
| 25 | <moqui.resource.DbResource | 25 | <moqui.resource.DbResource |
| 26 | resourceId="WIKI_MCP_SCREEN_DOCS" | 26 | resourceId="WIKI_MCP_SCREEN_DOCS" |
| 27 | parentResourceId="" | 27 | parentResourceId="WIKI_SPACE_ROOT" |
| 28 | filename="MCP_SCREEN_DOCS.md" | 28 | filename="MCP_SCREEN_DOCS.md" |
| 29 | isFile="Y"/> | 29 | isFile="Y"/> |
| 30 | <moqui.resource.DbResourceFile | 30 | <moqui.resource.DbResourceFile |
| ... | @@ -653,23 +653,46 @@ parameters: {productFeatureId: "ColorBlue"} | ... | @@ -653,23 +653,46 @@ parameters: {productFeatureId: "ColorBlue"} |
| 653 | 653 | ||
| 654 | ## Creating Products | 654 | ## Creating Products |
| 655 | 655 | ||
| 656 | **IMPORTANT: productId vs pseudoId** | ||
| 657 | |||
| 658 | When creating products, Moqui uses TWO different identifiers: | ||
| 659 | - **pseudoId**: User-visible ID (e.g., `DEMO_VAR_GR_SM`) - used for display and searches | ||
| 660 | - **productId**: Internal database ID (e.g., `100000`) - auto-generated, used by all services | ||
| 661 | |||
| 662 | After calling `createProduct`, the response includes `productId` in the result. **You must use this internal productId** (not the pseudoId) for all subsequent operations like: | ||
| 663 | - Adding features (`createProductFeature`) | ||
| 664 | - Creating associations (`createProductAssoc`) | ||
| 665 | - Setting prices (`createProductPrice`) | ||
| 666 | |||
| 667 | Example response after createProduct: | ||
| 668 | ``` | ||
| 669 | actionResult: { | ||
| 670 | status: "executed", | ||
| 671 | result: { productId: "100000" } // Use THIS ID | ||
| 672 | } | ||
| 673 | ``` | ||
| 674 | |||
| 656 | ### Standard Product | 675 | ### Standard Product |
| 657 | ``` | 676 | ``` |
| 658 | action: createProduct | 677 | action: createProduct |
| 659 | parameters: { | 678 | parameters: { |
| 679 | pseudoId: "MY_WIDGET_001", // Optional user-visible ID | ||
| 660 | productName: "New Widget", | 680 | productName: "New Widget", |
| 661 | productTypeEnumId: "PtAsset", | 681 | productTypeEnumId: "PtAsset", |
| 662 | assetTypeEnumId: "AstTpInventory" | 682 | assetTypeEnumId: "AstTpInventory" |
| 663 | } | 683 | } |
| 684 | // Response includes: productId: "100XXX" - use this for subsequent calls | ||
| 664 | ``` | 685 | ``` |
| 665 | 686 | ||
| 666 | ### Virtual Product (for variants) | 687 | ### Virtual Product (for variants) |
| 667 | ``` | 688 | ``` |
| 668 | action: createProduct | 689 | action: createProduct |
| 669 | parameters: { | 690 | parameters: { |
| 691 | pseudoId: "MY_VIRTUAL_001", // Optional user-visible ID | ||
| 670 | productName: "Widget with Options", | 692 | productName: "Widget with Options", |
| 671 | productTypeEnumId: "PtVirtual" | 693 | productTypeEnumId: "PtVirtual" |
| 672 | } | 694 | } |
| 695 | // Response includes: productId: "100XXX" - use this for subsequent calls | ||
| 673 | ``` | 696 | ``` |
| 674 | 697 | ||
| 675 | ## Product Types | 698 | ## Product Types |
| ... | @@ -723,7 +746,9 @@ Manage product details, features, prices, categories, and associations. | ... | @@ -723,7 +746,9 @@ Manage product details, features, prices, categories, and associations. |
| 723 | 746 | ||
| 724 | ## Required Parameter | 747 | ## Required Parameter |
| 725 | 748 | ||
| 726 | - **productId**: Product to edit (e.g., `DEMO_1`, `100000`) | 749 | - **productId**: Internal product ID to edit (e.g., `DEMO_1`, `100000`) |
| 750 | |||
| 751 | **Note:** Always use the internal `productId` (returned from createProduct), NOT the user-visible `pseudoId`. Using pseudoId will cause referential integrity errors. | ||
| 727 | 752 | ||
| 728 | ## Key Subscreens | 753 | ## Key Subscreens |
| 729 | 754 | ||
| ... | @@ -735,9 +760,11 @@ Manage product details, features, prices, categories, and associations. | ... | @@ -735,9 +760,11 @@ Manage product details, features, prices, categories, and associations. |
| 735 | 760 | ||
| 736 | ## Managing Features | 761 | ## Managing Features |
| 737 | 762 | ||
| 763 | Features are managed directly on the EditProduct screen (not a separate EditFeatures subscreen). | ||
| 764 | |||
| 738 | ### Add Selectable Feature (Virtual Products) | 765 | ### Add Selectable Feature (Virtual Products) |
| 739 | ``` | 766 | ``` |
| 740 | path: PopCommerce/PopCommerceAdmin/Catalog/Product/EditProduct/EditFeatures | 767 | path: PopCommerce/PopCommerceAdmin/Catalog/Product/EditProduct |
| 741 | action: createProductFeature | 768 | action: createProductFeature |
| 742 | parameters: { | 769 | parameters: { |
| 743 | productId: "DEMO_VAR", | 770 | productId: "DEMO_VAR", |
| ... | @@ -748,6 +775,7 @@ parameters: { | ... | @@ -748,6 +775,7 @@ parameters: { |
| 748 | 775 | ||
| 749 | ### Add Distinguishing Feature (Variant Products) | 776 | ### Add Distinguishing Feature (Variant Products) |
| 750 | ``` | 777 | ``` |
| 778 | path: PopCommerce/PopCommerceAdmin/Catalog/Product/EditProduct | ||
| 751 | action: createProductFeature | 779 | action: createProductFeature |
| 752 | parameters: { | 780 | parameters: { |
| 753 | productId: "DEMO_VAR_RED", | 781 | productId: "DEMO_VAR_RED", |
| ... | @@ -765,9 +793,11 @@ parameters: { | ... | @@ -765,9 +793,11 @@ parameters: { |
| 765 | 793 | ||
| 766 | ## Managing Associations | 794 | ## Managing Associations |
| 767 | 795 | ||
| 796 | EditAssocs is a sibling screen to EditProduct under Product (not a child of EditProduct). | ||
| 797 | |||
| 768 | ### Link Variant to Parent | 798 | ### Link Variant to Parent |
| 769 | ``` | 799 | ``` |
| 770 | path: PopCommerce/PopCommerceAdmin/Catalog/Product/EditProduct/EditAssocs | 800 | path: PopCommerce/PopCommerceAdmin/Catalog/Product/EditAssocs |
| 771 | action: createProductAssoc | 801 | action: createProductAssoc |
| 772 | parameters: { | 802 | parameters: { |
| 773 | productId: "DEMO_VAR", | 803 | productId: "DEMO_VAR", |
| ... | @@ -807,4 +837,400 @@ parameters: { | ... | @@ -807,4 +837,400 @@ parameters: { |
| 807 | userId="EX_JOHN_DOE" | 837 | userId="EX_JOHN_DOE" |
| 808 | changeDateTime="2025-01-19 00:00:00.000"/> | 838 | changeDateTime="2025-01-19 00:00:00.000"/> |
| 809 | 839 | ||
| 840 | <!-- ============================================== --> | ||
| 841 | <!-- MCP Service Documentation Wiki Space --> | ||
| 842 | <!-- Referenced by describedby in ARIA responses --> | ||
| 843 | <!-- ============================================== --> | ||
| 844 | |||
| 845 | <moqui.resource.wiki.WikiSpace wikiSpaceId="MCP_SERVICE_DOCS" | ||
| 846 | description="MCP Service Documentation - Parameter and usage docs for common services" | ||
| 847 | restrictView="N" | ||
| 848 | restrictUpdate="Y" | ||
| 849 | rootPageLocation="dbresource://WikiSpace/MCP_SERVICE_DOCS.md"/> | ||
| 850 | |||
| 851 | <!-- Service Docs Root --> | ||
| 852 | <moqui.resource.DbResource | ||
| 853 | resourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 854 | parentResourceId="WIKI_SPACE_ROOT" | ||
| 855 | filename="MCP_SERVICE_DOCS.md" | ||
| 856 | isFile="Y"/> | ||
| 857 | <moqui.resource.DbResourceFile | ||
| 858 | resourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 859 | mimeType="text/markdown" | ||
| 860 | versionName="v1"> | ||
| 861 | <fileData><![CDATA[# MCP Service Documentation | ||
| 862 | |||
| 863 | Documentation for common Moqui services used via MCP actions. | ||
| 864 | |||
| 865 | Services are referenced by `describedby` in ARIA mode responses. | ||
| 866 | |||
| 867 | ## Service Naming Convention | ||
| 868 | |||
| 869 | - `create#Entity` - Create new record | ||
| 870 | - `update#Entity` - Update existing record | ||
| 871 | - `delete#Entity` - Delete record | ||
| 872 | - `store#Entity` - Create or update (upsert) | ||
| 873 | - `Namespace.ServiceName.verb#Noun` - Custom service]]></fileData> | ||
| 874 | </moqui.resource.DbResourceFile> | ||
| 875 | |||
| 876 | <!-- ==================== --> | ||
| 877 | <!-- Product Services --> | ||
| 878 | <!-- ==================== --> | ||
| 879 | |||
| 880 | <!-- Product Entity --> | ||
| 881 | <moqui.resource.DbResource | ||
| 882 | resourceId="WIKI_SVC_PRODUCT" | ||
| 883 | parentResourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 884 | filename="Product.md" | ||
| 885 | isFile="Y"/> | ||
| 886 | <moqui.resource.DbResourceFile | ||
| 887 | resourceId="WIKI_SVC_PRODUCT" | ||
| 888 | mimeType="text/markdown" | ||
| 889 | versionName="v1"> | ||
| 890 | <fileData><![CDATA[# Product Service | ||
| 891 | |||
| 892 | Create and update products. | ||
| 893 | |||
| 894 | ## create#mantle.product.Product | ||
| 895 | |||
| 896 | **Required:** | ||
| 897 | - `productName` (string): Display name | ||
| 898 | |||
| 899 | **Optional:** | ||
| 900 | - `pseudoId` (string): User-visible ID (auto-generated if omitted) | ||
| 901 | - `productTypeEnumId`: PtAsset (default), PtVirtual, PtService, PtDigital | ||
| 902 | - `ownerPartyId`: Organization that owns this product | ||
| 903 | - `description`: Long description | ||
| 904 | - `assetTypeEnumId`: For physical products - AstTpInventory, AstTpFixed | ||
| 905 | - `assetClassEnumId`: AstClsInventoryFin (standard inventory) | ||
| 906 | |||
| 907 | **Returns:** | ||
| 908 | - `productId`: Internal ID (use this for subsequent operations) | ||
| 909 | |||
| 910 | **Example:** | ||
| 911 | ``` | ||
| 912 | action: createProduct | ||
| 913 | parameters: { | ||
| 914 | productName: "Blue Widget", | ||
| 915 | productTypeEnumId: "PtAsset", | ||
| 916 | ownerPartyId: "ORG_ZIZI_RETAIL" | ||
| 917 | } | ||
| 918 | ``` | ||
| 919 | |||
| 920 | ## update#mantle.product.Product | ||
| 921 | |||
| 922 | **Required:** | ||
| 923 | - `productId`: Internal product ID to update | ||
| 924 | |||
| 925 | **Optional:** Any Product field (productName, description, etc.)]]></fileData> | ||
| 926 | </moqui.resource.DbResourceFile> | ||
| 927 | |||
| 928 | <moqui.resource.wiki.WikiPage | ||
| 929 | wikiPageId="MCP_SERVICE_DOCS/Product" | ||
| 930 | wikiSpaceId="MCP_SERVICE_DOCS" | ||
| 931 | pagePath="Product" | ||
| 932 | publishedVersionName="v1" | ||
| 933 | restrictView="N"/> | ||
| 934 | |||
| 935 | <!-- ProductFeature Service --> | ||
| 936 | <moqui.resource.DbResource | ||
| 937 | resourceId="WIKI_SVC_PRODUCT_FEATURE" | ||
| 938 | parentResourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 939 | filename="ProductFeature.md" | ||
| 940 | isFile="Y"/> | ||
| 941 | <moqui.resource.DbResourceFile | ||
| 942 | resourceId="WIKI_SVC_PRODUCT_FEATURE" | ||
| 943 | mimeType="text/markdown" | ||
| 944 | versionName="v1"> | ||
| 945 | <fileData><![CDATA[# ProductFeature Service | ||
| 946 | |||
| 947 | Apply features (like color, size) to products. | ||
| 948 | |||
| 949 | ## mantle.product.ProductServices.create#ProductFeature | ||
| 950 | |||
| 951 | **Required:** | ||
| 952 | - `productId`: Product to add feature to | ||
| 953 | - `productFeatureId`: Feature ID (e.g., ColorRed, SizeMedium) | ||
| 954 | - `applTypeEnumId`: How feature applies to product | ||
| 955 | |||
| 956 | **Application Types (applTypeEnumId):** | ||
| 957 | - `PfatSelectable`: Customer can choose (use on virtual parent) | ||
| 958 | - `PfatDistinguishing`: Identifies specific variant (use on variants) | ||
| 959 | - `PfatStandard`: Always included | ||
| 960 | - `PfatRequired`: Must be selected at purchase | ||
| 961 | |||
| 962 | **Optional:** | ||
| 963 | - `fromDate`: When feature becomes active (default: now) | ||
| 964 | - `sequenceNum`: Display order | ||
| 965 | |||
| 966 | **Example - Virtual Parent:** | ||
| 967 | ``` | ||
| 968 | action: createProductFeature | ||
| 969 | parameters: { | ||
| 970 | productId: "DEMO_VAR", | ||
| 971 | productFeatureId: "ColorGreen", | ||
| 972 | applTypeEnumId: "PfatSelectable" | ||
| 973 | } | ||
| 974 | ``` | ||
| 975 | |||
| 976 | **Example - Variant:** | ||
| 977 | ``` | ||
| 978 | action: createProductFeature | ||
| 979 | parameters: { | ||
| 980 | productId: "DEMO_VAR_GR_SM", | ||
| 981 | productFeatureId: "ColorGreen", | ||
| 982 | applTypeEnumId: "PfatDistinguishing" | ||
| 983 | } | ||
| 984 | ``` | ||
| 985 | |||
| 986 | ## Common Feature IDs | ||
| 987 | |||
| 988 | Colors: ColorRed, ColorBlue, ColorGreen, ColorBlack, ColorWhite | ||
| 989 | Sizes: SizeSmall, SizeMedium, SizeLarge, SizeXL]]></fileData> | ||
| 990 | </moqui.resource.DbResourceFile> | ||
| 991 | |||
| 992 | <moqui.resource.wiki.WikiPage | ||
| 993 | wikiPageId="MCP_SERVICE_DOCS/ProductFeature" | ||
| 994 | wikiSpaceId="MCP_SERVICE_DOCS" | ||
| 995 | pagePath="ProductFeature" | ||
| 996 | publishedVersionName="v1" | ||
| 997 | restrictView="N"/> | ||
| 998 | |||
| 999 | <!-- ProductAssoc Service --> | ||
| 1000 | <moqui.resource.DbResource | ||
| 1001 | resourceId="WIKI_SVC_PRODUCT_ASSOC" | ||
| 1002 | parentResourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 1003 | filename="ProductAssoc.md" | ||
| 1004 | isFile="Y"/> | ||
| 1005 | <moqui.resource.DbResourceFile | ||
| 1006 | resourceId="WIKI_SVC_PRODUCT_ASSOC" | ||
| 1007 | mimeType="text/markdown" | ||
| 1008 | versionName="v1"> | ||
| 1009 | <fileData><![CDATA[# ProductAssoc Service | ||
| 1010 | |||
| 1011 | Link products together (variants, accessories, etc.) | ||
| 1012 | |||
| 1013 | ## create#mantle.product.ProductAssoc | ||
| 1014 | |||
| 1015 | **Required:** | ||
| 1016 | - `productId`: Parent/source product | ||
| 1017 | - `toProductId`: Related product | ||
| 1018 | - `productAssocTypeEnumId`: Type of relationship | ||
| 1019 | |||
| 1020 | **Association Types:** | ||
| 1021 | - `PatVariant`: Links virtual parent to variant product | ||
| 1022 | - `PatAccessory`: Suggested accessory | ||
| 1023 | - `PatComplement`: Complementary product | ||
| 1024 | - `PatSubstitute`: Equivalent alternative | ||
| 1025 | - `PatUpSell`: Higher-tier recommendation | ||
| 1026 | - `PatCrossSell`: Related product suggestion | ||
| 1027 | |||
| 1028 | **Optional:** | ||
| 1029 | - `fromDate`: When association starts (default: now) | ||
| 1030 | - `quantity`: For BOM/kits | ||
| 1031 | - `sequenceNum`: Display order | ||
| 1032 | |||
| 1033 | **Example - Link Variant:** | ||
| 1034 | ``` | ||
| 1035 | action: createProductAssoc | ||
| 1036 | parameters: { | ||
| 1037 | productId: "DEMO_VAR", | ||
| 1038 | toProductId: "DEMO_VAR_GR_SM", | ||
| 1039 | productAssocTypeEnumId: "PatVariant" | ||
| 1040 | } | ||
| 1041 | ``` | ||
| 1042 | |||
| 1043 | **Note:** Use internal `productId` values, not pseudoId.]]></fileData> | ||
| 1044 | </moqui.resource.DbResourceFile> | ||
| 1045 | |||
| 1046 | <moqui.resource.wiki.WikiPage | ||
| 1047 | wikiPageId="MCP_SERVICE_DOCS/ProductAssoc" | ||
| 1048 | wikiSpaceId="MCP_SERVICE_DOCS" | ||
| 1049 | pagePath="ProductAssoc" | ||
| 1050 | publishedVersionName="v1" | ||
| 1051 | restrictView="N"/> | ||
| 1052 | |||
| 1053 | <!-- ProductPrice Service --> | ||
| 1054 | <moqui.resource.DbResource | ||
| 1055 | resourceId="WIKI_SVC_PRODUCT_PRICE" | ||
| 1056 | parentResourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 1057 | filename="ProductPrice.md" | ||
| 1058 | isFile="Y"/> | ||
| 1059 | <moqui.resource.DbResourceFile | ||
| 1060 | resourceId="WIKI_SVC_PRODUCT_PRICE" | ||
| 1061 | mimeType="text/markdown" | ||
| 1062 | versionName="v1"> | ||
| 1063 | <fileData><![CDATA[# ProductPrice Service | ||
| 1064 | |||
| 1065 | Manage product pricing. | ||
| 1066 | |||
| 1067 | ## create#mantle.product.ProductPrice | ||
| 1068 | |||
| 1069 | **Required:** | ||
| 1070 | - `productId`: Product to price | ||
| 1071 | - `price`: Amount | ||
| 1072 | - `priceTypeEnumId`: Type of price | ||
| 1073 | - `pricePurposeEnumId`: Purpose/context | ||
| 1074 | |||
| 1075 | **Price Types (priceTypeEnumId):** | ||
| 1076 | - `PptList`: Standard list price | ||
| 1077 | - `PptCurrent`: Current selling price | ||
| 1078 | - `PptMin`: Minimum allowed price | ||
| 1079 | - `PptMax`: Maximum allowed price | ||
| 1080 | - `PptCompetitive`: Competitor price | ||
| 1081 | - `PptPromotional`: Sale/promo price | ||
| 1082 | |||
| 1083 | **Price Purposes (pricePurposeEnumId):** | ||
| 1084 | - `PppPurchase`: Buying price (from supplier) | ||
| 1085 | - `PppSales`: Selling price (to customer) | ||
| 1086 | |||
| 1087 | **Optional:** | ||
| 1088 | - `priceUomId`: Currency (default: USD) | ||
| 1089 | - `minQuantity`: Quantity break (for tiered pricing) | ||
| 1090 | - `fromDate`: When price becomes active | ||
| 1091 | - `thruDate`: When price expires | ||
| 1092 | - `productStoreId`: Store-specific pricing | ||
| 1093 | |||
| 1094 | **Example - Basic Price:** | ||
| 1095 | ``` | ||
| 1096 | action: createProductPrice | ||
| 1097 | parameters: { | ||
| 1098 | productId: "DEMO_VAR_GR_SM", | ||
| 1099 | price: 19.99, | ||
| 1100 | priceTypeEnumId: "PptList", | ||
| 1101 | pricePurposeEnumId: "PppSales", | ||
| 1102 | priceUomId: "USD" | ||
| 1103 | } | ||
| 1104 | ``` | ||
| 1105 | |||
| 1106 | **Example - Quantity Break:** | ||
| 1107 | ``` | ||
| 1108 | action: createProductPrice | ||
| 1109 | parameters: { | ||
| 1110 | productId: "DEMO_VAR_GR_SM", | ||
| 1111 | price: 17.99, | ||
| 1112 | priceTypeEnumId: "PptList", | ||
| 1113 | pricePurposeEnumId: "PppSales", | ||
| 1114 | minQuantity: 10 | ||
| 1115 | } | ||
| 1116 | ```]]></fileData> | ||
| 1117 | </moqui.resource.DbResourceFile> | ||
| 1118 | |||
| 1119 | <moqui.resource.wiki.WikiPage | ||
| 1120 | wikiPageId="MCP_SERVICE_DOCS/ProductPrice" | ||
| 1121 | wikiSpaceId="MCP_SERVICE_DOCS" | ||
| 1122 | pagePath="ProductPrice" | ||
| 1123 | publishedVersionName="v1" | ||
| 1124 | restrictView="N"/> | ||
| 1125 | |||
| 1126 | <!-- ==================== --> | ||
| 1127 | <!-- Party Services --> | ||
| 1128 | <!-- ==================== --> | ||
| 1129 | |||
| 1130 | <!-- Person Service --> | ||
| 1131 | <moqui.resource.DbResource | ||
| 1132 | resourceId="WIKI_SVC_PERSON" | ||
| 1133 | parentResourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 1134 | filename="Person.md" | ||
| 1135 | isFile="Y"/> | ||
| 1136 | <moqui.resource.DbResourceFile | ||
| 1137 | resourceId="WIKI_SVC_PERSON" | ||
| 1138 | mimeType="text/markdown" | ||
| 1139 | versionName="v1"> | ||
| 1140 | <fileData><![CDATA[# Person Service | ||
| 1141 | |||
| 1142 | Create and manage people (parties). | ||
| 1143 | |||
| 1144 | ## mantle.party.PartyServices.create#Person | ||
| 1145 | |||
| 1146 | **Required:** | ||
| 1147 | - `firstName`: First name | ||
| 1148 | - `lastName`: Last name | ||
| 1149 | |||
| 1150 | **Optional:** | ||
| 1151 | - `middleName`: Middle name | ||
| 1152 | - `emailAddress`: Primary email | ||
| 1153 | - `roleTypeId`: Role (Customer, Employee, etc.) | ||
| 1154 | - `ownerPartyId`: Organization that owns this party record | ||
| 1155 | |||
| 1156 | **Returns:** | ||
| 1157 | - `partyId`: Internal party ID | ||
| 1158 | |||
| 1159 | **Example:** | ||
| 1160 | ``` | ||
| 1161 | action: createPerson | ||
| 1162 | parameters: { | ||
| 1163 | firstName: "Jane", | ||
| 1164 | lastName: "Smith", | ||
| 1165 | emailAddress: "jane@example.com" | ||
| 1166 | } | ||
| 1167 | ``` | ||
| 1168 | |||
| 1169 | ## mantle.party.PartyServices.update#PartyDetail | ||
| 1170 | |||
| 1171 | **Required:** | ||
| 1172 | - `partyId`: Party to update | ||
| 1173 | |||
| 1174 | **Optional:** firstName, lastName, middleName, comments, etc.]]></fileData> | ||
| 1175 | </moqui.resource.DbResourceFile> | ||
| 1176 | |||
| 1177 | <moqui.resource.wiki.WikiPage | ||
| 1178 | wikiPageId="MCP_SERVICE_DOCS/Person" | ||
| 1179 | wikiSpaceId="MCP_SERVICE_DOCS" | ||
| 1180 | pagePath="Person" | ||
| 1181 | publishedVersionName="v1" | ||
| 1182 | restrictView="N"/> | ||
| 1183 | |||
| 1184 | <!-- CommunicationEvent (Message) Service --> | ||
| 1185 | <moqui.resource.DbResource | ||
| 1186 | resourceId="WIKI_SVC_MESSAGE" | ||
| 1187 | parentResourceId="WIKI_MCP_SERVICE_DOCS" | ||
| 1188 | filename="Message.md" | ||
| 1189 | isFile="Y"/> | ||
| 1190 | <moqui.resource.DbResourceFile | ||
| 1191 | resourceId="WIKI_SVC_MESSAGE" | ||
| 1192 | mimeType="text/markdown" | ||
| 1193 | versionName="v1"> | ||
| 1194 | <fileData><![CDATA[# Message Service | ||
| 1195 | |||
| 1196 | Send messages between parties. | ||
| 1197 | |||
| 1198 | ## mantle.party.CommunicationServices.create#Message | ||
| 1199 | |||
| 1200 | **Required:** | ||
| 1201 | - `toPartyId`: Recipient party ID | ||
| 1202 | - `subject`: Message subject | ||
| 1203 | - `body`: Message content | ||
| 1204 | |||
| 1205 | **Optional:** | ||
| 1206 | - `fromPartyId`: Sender (defaults to current user) | ||
| 1207 | - `purposeEnumId`: Message purpose | ||
| 1208 | - `communicationEventTypeId`: Message type | ||
| 1209 | |||
| 1210 | **Purpose Types (purposeEnumId):** | ||
| 1211 | - `CpComment`: General comment | ||
| 1212 | - `CpSupport`: Support request | ||
| 1213 | - `CpActivityRequest`: Activity/task request | ||
| 1214 | - `CpBilling`: Billing inquiry | ||
| 1215 | |||
| 1216 | **Example:** | ||
| 1217 | ``` | ||
| 1218 | action: createMessage | ||
| 1219 | parameters: { | ||
| 1220 | toPartyId: "EX_JOHN_DOE", | ||
| 1221 | subject: "Green variants ready", | ||
| 1222 | body: "The green product variants have been created and are ready for review." | ||
| 1223 | } | ||
| 1224 | ``` | ||
| 1225 | |||
| 1226 | **Screen Path:** Party/PartyMessages]]></fileData> | ||
| 1227 | </moqui.resource.DbResourceFile> | ||
| 1228 | |||
| 1229 | <moqui.resource.wiki.WikiPage | ||
| 1230 | wikiPageId="MCP_SERVICE_DOCS/Message" | ||
| 1231 | wikiSpaceId="MCP_SERVICE_DOCS" | ||
| 1232 | pagePath="Message" | ||
| 1233 | publishedVersionName="v1" | ||
| 1234 | restrictView="N"/> | ||
| 1235 | |||
| 810 | </entity-facade-xml> | 1236 | </entity-facade-xml> | ... | ... |
| ... | @@ -59,4 +59,10 @@ | ... | @@ -59,4 +59,10 @@ |
| 59 | <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_JD" fromDate="2025-01-01 00:00:00.000"/> | 59 | <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_JD" fromDate="2025-01-01 00:00:00.000"/> |
| 60 | <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_BD" fromDate="2025-01-01 00:00:00.000"/> | 60 | <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_BD" fromDate="2025-01-01 00:00:00.000"/> |
| 61 | --> | 61 | --> |
| 62 | |||
| 63 | <!-- Add EX_JOHN_DOE to PopcAdminSales so JohnSales can see/message John Doe in searches --> | ||
| 64 | <moqui.security.UserGroupMember userGroupId="PopcAdminSales" userId="EX_JOHN_DOE" fromDate="2025-01-01 00:00:00.000"/> | ||
| 65 | |||
| 66 | <!-- Set EX_JOHN_DOE's ownerPartyId to ORG_ZIZI_RETAIL so they appear in org-filtered searches --> | ||
| 67 | <mantle.party.Party partyId="EX_JOHN_DOE" ownerPartyId="ORG_ZIZI_RETAIL"/> | ||
| 62 | </entity-facade-xml> | 68 | </entity-facade-xml> | ... | ... |
| ... | @@ -128,6 +128,11 @@ | ... | @@ -128,6 +128,11 @@ |
| 128 | <#assign formName = (.node["@name"]!"")?string> | 128 | <#assign formName = (.node["@name"]!"")?string> |
| 129 | <#assign fieldMetaList = []> | 129 | <#assign fieldMetaList = []> |
| 130 | <#assign dummy = ec.resource.expression("if (mcpSemanticData.formMetadata == null) mcpSemanticData.formMetadata = [:]; mcpSemanticData.formMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', map: '" + (mapName!"")?js_string + "'])", "")!> | 130 | <#assign dummy = ec.resource.expression("if (mcpSemanticData.formMetadata == null) mcpSemanticData.formMetadata = [:]; mcpSemanticData.formMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', map: '" + (mapName!"")?js_string + "'])", "")!> |
| 131 | <#-- Store the actual form data (current entity values) in semanticData --> | ||
| 132 | <#if formMap?has_content> | ||
| 133 | <#assign dummy = ec.context.put("tempFormMapData", formMap)!> | ||
| 134 | <#assign dummy = ec.resource.expression("mcpSemanticData.put('" + formName?js_string + "_data', tempFormMapData)", "")!> | ||
| 135 | </#if> | ||
| 131 | </#if> | 136 | </#if> |
| 132 | <#t>${sri.pushSingleFormMapContext(mapName)} | 137 | <#t>${sri.pushSingleFormMapContext(mapName)} |
| 133 | <#list formNode["field"] as fieldNode> | 138 | <#list formNode["field"] as fieldNode> | ... | ... |
| ... | @@ -195,7 +195,9 @@ | ... | @@ -195,7 +195,9 @@ |
| 195 | // Handle internal discovery/utility tools | 195 | // Handle internal discovery/utility tools |
| 196 | def internalToolMappings = [ | 196 | def internalToolMappings = [ |
| 197 | "moqui_search_screens": "McpServices.mcp#SearchScreens", | 197 | "moqui_search_screens": "McpServices.mcp#SearchScreens", |
| 198 | "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails" | 198 | "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails", |
| 199 | "moqui_get_help": "McpServices.mcp#GetHelp", | ||
| 200 | "moqui_batch_operations": "McpServices.mcp#BatchOperations" | ||
| 199 | ] | 201 | ] |
| 200 | 202 | ||
| 201 | def targetServiceName = internalToolMappings[name] | 203 | def targetServiceName = internalToolMappings[name] |
| ... | @@ -759,6 +761,7 @@ serializeMoquiObject = { obj, depth = 0 -> | ... | @@ -759,6 +761,7 @@ serializeMoquiObject = { obj, depth = 0 -> |
| 759 | 761 | ||
| 760 | // Convert MCP semantic state to ARIA accessibility tree format | 762 | // Convert MCP semantic state to ARIA accessibility tree format |
| 761 | // This produces a compact, standard representation matching W3C ARIA roles | 763 | // This produces a compact, standard representation matching W3C ARIA roles |
| 764 | // Following ARIA naming guidelines: name (short label), description (brief explanation), details (extended help) | ||
| 762 | def convertToAriaTree = { semanticState, targetScreenPath -> | 765 | def convertToAriaTree = { semanticState, targetScreenPath -> |
| 763 | if (!semanticState) return null | 766 | if (!semanticState) return null |
| 764 | 767 | ||
| ... | @@ -781,55 +784,91 @@ def convertToAriaTree = { semanticState, targetScreenPath -> | ... | @@ -781,55 +784,91 @@ def convertToAriaTree = { semanticState, targetScreenPath -> |
| 781 | case "number": return "spinbutton" | 784 | case "number": return "spinbutton" |
| 782 | case "password": return "textbox" | 785 | case "password": return "textbox" |
| 783 | case "hidden": return null // skip hidden fields | 786 | case "hidden": return null // skip hidden fields |
| 787 | case "display": return "text" // read-only display | ||
| 788 | case "link": return "link" | ||
| 784 | default: return "textbox" | 789 | default: return "textbox" |
| 785 | } | 790 | } |
| 786 | } | 791 | } |
| 787 | 792 | ||
| 788 | // Convert a field to ARIA node | 793 | // Convert a field to ARIA node with full attributes |
| 789 | def fieldToAriaNode = { field -> | 794 | def fieldToAriaNode = { field, formDataMap -> |
| 790 | def role = fieldToAriaRole(field) | 795 | def role = fieldToAriaRole(field) |
| 791 | if (!role) return null | 796 | if (!role) return null |
| 792 | 797 | ||
| 793 | def node = [ | 798 | def node = [ |
| 794 | role: role, | 799 | role: role, |
| 795 | name: field.title ?: field.name | 800 | name: field.title ?: field.name, |
| 801 | ref: field.name // Reference for interaction | ||
| 796 | ] | 802 | ] |
| 797 | 803 | ||
| 804 | // Add current value if available from form data | ||
| 805 | if (formDataMap && formDataMap[field.name] != null) { | ||
| 806 | node.value = formDataMap[field.name]?.toString() | ||
| 807 | } | ||
| 808 | |||
| 798 | // Add required attribute | 809 | // Add required attribute |
| 799 | if (field.required) node.required = true | 810 | if (field.required) node.required = true |
| 800 | 811 | ||
| 801 | // For dropdowns, show option count instead of all options | 812 | // For dropdowns, show option count and examples |
| 802 | if (field.type == "dropdown") { | 813 | if (field.type == "dropdown") { |
| 803 | def optionCount = field.options?.size() ?: 0 | 814 | def optionCount = field.options?.size() ?: 0 |
| 804 | if (field.totalOptions) optionCount = field.totalOptions | 815 | if (field.totalOptions) optionCount = field.totalOptions |
| 805 | if (optionCount > 0) { | 816 | if (optionCount > 0) { |
| 806 | node.options = optionCount | 817 | node.options = optionCount |
| 818 | // Show first few example values | ||
| 819 | if (field.options instanceof List && field.options.size() > 0) { | ||
| 820 | node.examples = field.options.take(3).collect { opt -> | ||
| 821 | opt instanceof Map ? (opt.value ?: opt.label) : opt.toString() | ||
| 822 | } | ||
| 823 | } | ||
| 807 | if (field.optionsTruncated) { | 824 | if (field.optionsTruncated) { |
| 808 | node.fetchHint = field.fetchHint | 825 | node.description = "Use moqui_get_screen_details for all ${optionCount} options" |
| 809 | } | 826 | } |
| 810 | } | 827 | } |
| 811 | // Check for dynamic options | 828 | // Check for dynamic options |
| 812 | if (field.dynamicOptions) { | 829 | if (field.dynamicOptions) { |
| 813 | node.autocomplete = true | 830 | node.autocomplete = true |
| 831 | node.description = "Type to search, options load dynamically" | ||
| 814 | } | 832 | } |
| 815 | } | 833 | } |
| 816 | 834 | ||
| 817 | return node | 835 | return node |
| 818 | } | 836 | } |
| 819 | 837 | ||
| 838 | // Generate description for an action based on its name and service | ||
| 839 | def actionDescription = { actionName, serviceName -> | ||
| 840 | def verb = actionName.replaceAll(/([A-Z])/, ' $1').trim().toLowerCase() | ||
| 841 | if (serviceName) { | ||
| 842 | // Extract entity/operation from service name like "create#mantle.product.Product" | ||
| 843 | def parts = serviceName.split('#') | ||
| 844 | if (parts.length == 2) { | ||
| 845 | def operation = parts[0] | ||
| 846 | def entity = parts[1].split('\\.').last() | ||
| 847 | return "${operation} ${entity}" | ||
| 848 | } | ||
| 849 | return serviceName.split('\\.').last() | ||
| 850 | } | ||
| 851 | return verb | ||
| 852 | } | ||
| 853 | |||
| 820 | // Process forms (form-single types become form landmarks) | 854 | // Process forms (form-single types become form landmarks) |
| 821 | formMetadata.each { formName, formData -> | 855 | formMetadata.each { formName, formData -> |
| 822 | if (formData.type == "form-list") return // Handle separately | 856 | if (formData.type == "form-list") return // Handle separately |
| 823 | 857 | ||
| 858 | // Look for form data (current values) | ||
| 859 | def formDataKey = "${formName}_data" | ||
| 860 | def formDataMap = data[formDataKey] ?: [:] | ||
| 861 | |||
| 824 | def formNode = [ | 862 | def formNode = [ |
| 825 | role: "form", | 863 | role: "form", |
| 826 | name: formData.name ?: formName, | 864 | name: formData.name ?: formName, |
| 865 | ref: formName, | ||
| 827 | children: [] | 866 | children: [] |
| 828 | ] | 867 | ] |
| 829 | 868 | ||
| 830 | // Add fields | 869 | // Add fields with values |
| 831 | formData.fields?.each { field -> | 870 | formData.fields?.each { field -> |
| 832 | def fieldNode = fieldToAriaNode(field) | 871 | def fieldNode = fieldToAriaNode(field, formDataMap) |
| 833 | if (fieldNode) formNode.children << fieldNode | 872 | if (fieldNode) formNode.children << fieldNode |
| 834 | } | 873 | } |
| 835 | 874 | ||
| ... | @@ -841,7 +880,15 @@ def convertToAriaTree = { semanticState, targetScreenPath -> | ... | @@ -841,7 +880,15 @@ def convertToAriaTree = { semanticState, targetScreenPath -> |
| 841 | a.name == expectedActionName || a.name?.equalsIgnoreCase(expectedActionName) | 880 | a.name == expectedActionName || a.name?.equalsIgnoreCase(expectedActionName) |
| 842 | } | 881 | } |
| 843 | if (submitAction) { | 882 | if (submitAction) { |
| 844 | formNode.children << [role: "button", name: submitAction.name] | 883 | def btnNode = [ |
| 884 | role: "button", | ||
| 885 | name: submitAction.name, | ||
| 886 | ref: submitAction.name | ||
| 887 | ] | ||
| 888 | if (submitAction.service) { | ||
| 889 | btnNode.description = actionDescription(submitAction.name, submitAction.service) | ||
| 890 | } | ||
| 891 | formNode.children << btnNode | ||
| 845 | } | 892 | } |
| 846 | 893 | ||
| 847 | if (formNode.children) children << formNode | 894 | if (formNode.children) children << formNode |
| ... | @@ -860,6 +907,7 @@ def convertToAriaTree = { semanticState, targetScreenPath -> | ... | @@ -860,6 +907,7 @@ def convertToAriaTree = { semanticState, targetScreenPath -> |
| 860 | def gridNode = [ | 907 | def gridNode = [ |
| 861 | role: "grid", | 908 | role: "grid", |
| 862 | name: formData.name ?: formName, | 909 | name: formData.name ?: formName, |
| 910 | ref: formName, | ||
| 863 | rowcount: itemCount | 911 | rowcount: itemCount |
| 864 | ] | 912 | ] |
| 865 | 913 | ||
| ... | @@ -867,14 +915,17 @@ def convertToAriaTree = { semanticState, targetScreenPath -> | ... | @@ -867,14 +915,17 @@ def convertToAriaTree = { semanticState, targetScreenPath -> |
| 867 | def columns = formData.fields?.collect { it.title ?: it.name } | 915 | def columns = formData.fields?.collect { it.title ?: it.name } |
| 868 | if (columns) gridNode.columns = columns | 916 | if (columns) gridNode.columns = columns |
| 869 | 917 | ||
| 870 | // Add sample rows (first 3) | 918 | // Add sample rows with more detail |
| 871 | if (listData instanceof List && listData.size() > 0) { | 919 | if (listData instanceof List && listData.size() > 0) { |
| 872 | gridNode.children = [] | 920 | gridNode.children = [] |
| 873 | listData.take(3).each { row -> | 921 | listData.take(3).each { row -> |
| 874 | def rowNode = [role: "row"] | 922 | def rowNode = [role: "row"] |
| 875 | // Extract key identifying info | 923 | // Extract key identifying info |
| 876 | def name = row.combinedName ?: row.name ?: row.productName ?: row.pseudoId ?: row.id | 924 | def id = row.pseudoId ?: row.partyId ?: row.productId ?: row.id |
| 925 | def name = row.combinedName ?: row.name ?: row.productName | ||
| 926 | if (id) rowNode.ref = id | ||
| 877 | if (name) rowNode.name = name | 927 | if (name) rowNode.name = name |
| 928 | if (id && name && id != name) rowNode.description = id | ||
| 878 | gridNode.children << rowNode | 929 | gridNode.children << rowNode |
| 879 | } | 930 | } |
| 880 | if (listData.size() > 3) { | 931 | if (listData.size() > 3) { |
| ... | @@ -892,7 +943,8 @@ def convertToAriaTree = { semanticState, targetScreenPath -> | ... | @@ -892,7 +943,8 @@ def convertToAriaTree = { semanticState, targetScreenPath -> |
| 892 | role: "navigation", | 943 | role: "navigation", |
| 893 | name: "Links", | 944 | name: "Links", |
| 894 | children: navLinks.take(10).collect { link -> | 945 | children: navLinks.take(10).collect { link -> |
| 895 | [role: "link", name: link.text, path: link.path] | 946 | def linkNode = [role: "link", name: link.text, ref: link.path] |
| 947 | linkNode | ||
| 896 | } | 948 | } |
| 897 | ] | 949 | ] |
| 898 | if (navLinks.size() > 10) { | 950 | if (navLinks.size() > 10) { |
| ... | @@ -901,27 +953,59 @@ def convertToAriaTree = { semanticState, targetScreenPath -> | ... | @@ -901,27 +953,59 @@ def convertToAriaTree = { semanticState, targetScreenPath -> |
| 901 | children << navNode | 953 | children << navNode |
| 902 | } | 954 | } |
| 903 | 955 | ||
| 904 | // Process actions as a toolbar | 956 | // Process ALL actions as buttons (unified - no separate transitions/actions) |
| 905 | def serviceActions = semanticState.actions?.findAll { it.type == "service-action" } | 957 | def allActions = semanticState.actions ?: [] |
| 906 | if (serviceActions && serviceActions.size() > 0) { | 958 | if (allActions && allActions.size() > 0) { |
| 907 | def toolbarNode = [ | 959 | def toolbarNode = [ |
| 908 | role: "toolbar", | 960 | role: "toolbar", |
| 909 | name: "Actions", | 961 | name: "Actions", |
| 910 | children: serviceActions.take(10).collect { action -> | 962 | description: "Available operations on this screen", |
| 911 | [role: "button", name: action.name] | 963 | children: allActions.take(15).collect { action -> |
| 964 | def btnNode = [ | ||
| 965 | role: "button", | ||
| 966 | name: action.name, | ||
| 967 | ref: action.name | ||
| 968 | ] | ||
| 969 | // Add description based on service or action type | ||
| 970 | if (action.service) { | ||
| 971 | btnNode.description = actionDescription(action.name, action.service) | ||
| 972 | btnNode.service = action.service | ||
| 973 | // Add describedby for service documentation | ||
| 974 | // e.g., "mantle.product.ProductServices.create#ProductFeature" -> "wiki:service:ProductServices.create#ProductFeature" | ||
| 975 | def serviceParts = action.service.split('\\.') | ||
| 976 | if (serviceParts.length > 0) { | ||
| 977 | btnNode.describedby = "wiki:service:${serviceParts[-1]}" | ||
| 978 | } | ||
| 979 | } else if (action.type == "screen-transition") { | ||
| 980 | btnNode.description = "Navigate" | ||
| 981 | } else if (action.type == "form-action") { | ||
| 982 | btnNode.description = "Form operation" | ||
| 983 | } | ||
| 984 | btnNode | ||
| 912 | } | 985 | } |
| 913 | ] | 986 | ] |
| 914 | if (serviceActions.size() > 10) { | 987 | if (allActions.size() > 15) { |
| 915 | toolbarNode.moreActions = serviceActions.size() - 10 | 988 | toolbarNode.moreActions = allActions.size() - 15 |
| 916 | } | 989 | } |
| 917 | children << toolbarNode | 990 | children << toolbarNode |
| 918 | } | 991 | } |
| 919 | 992 | ||
| 920 | return [ | 993 | // Build the main node |
| 994 | def mainNode = [ | ||
| 921 | role: "main", | 995 | role: "main", |
| 922 | name: targetScreenPath?.split('/')?.last() ?: "Screen", | 996 | name: targetScreenPath?.split('/')?.last() ?: "Screen", |
| 997 | description: "Use ref values with action parameter to interact", | ||
| 923 | children: children | 998 | children: children |
| 924 | ] | 999 | ] |
| 1000 | |||
| 1001 | // Add describedby reference if wiki instructions exist for this screen | ||
| 1002 | // This follows ARIA pattern: describedby points to extended documentation | ||
| 1003 | // Use full screen path since that's how WikiPage.pagePath is stored | ||
| 1004 | if (targetScreenPath) { | ||
| 1005 | mainNode.describedby = "wiki:screen:${targetScreenPath}" | ||
| 1006 | } | ||
| 1007 | |||
| 1008 | return mainNode | ||
| 925 | } | 1009 | } |
| 926 | 1010 | ||
| 927 | // Convert MCP semantic state to compact actionable format | 1011 | // Convert MCP semantic state to compact actionable format |
| ... | @@ -1049,16 +1133,60 @@ def convertToCompactFormat = { semanticState, targetScreenPath -> | ... | @@ -1049,16 +1133,60 @@ def convertToCompactFormat = { semanticState, targetScreenPath -> |
| 1049 | // Rows with key data and links | 1133 | // Rows with key data and links |
| 1050 | if (listData instanceof List && listData.size() > 0) { | 1134 | if (listData instanceof List && listData.size() > 0) { |
| 1051 | gridInfo.rowCount = listData.size() | 1135 | gridInfo.rowCount = listData.size() |
| 1136 | |||
| 1137 | // Get field names from form definition for determining key fields | ||
| 1138 | def fieldNames = formData.fields?.collect { it.name } ?: [] | ||
| 1139 | |||
| 1052 | gridInfo.rows = listData.take(10).collect { row -> | 1140 | gridInfo.rows = listData.take(10).collect { row -> |
| 1053 | def rowInfo = [:] | 1141 | def rowInfo = [:] |
| 1054 | 1142 | ||
| 1055 | // Get identifying info | 1143 | // Get identifying info |
| 1056 | def id = row.pseudoId ?: row.partyId ?: row.productId ?: row.orderId ?: row.id | 1144 | def id = row.pseudoId ?: row.partyId ?: row.productId ?: row.orderId ?: row.communicationEventId ?: row.id |
| 1057 | def name = row.combinedName ?: row.productName ?: row.name | 1145 | def name = row.combinedName ?: row.productName ?: row.organizationName ?: row.subject ?: row.name |
| 1058 | 1146 | ||
| 1059 | if (id) rowInfo.id = id | 1147 | if (id) rowInfo.id = id |
| 1060 | if (name && name != id) rowInfo.name = name | 1148 | if (name && name != id) rowInfo.name = name |
| 1061 | 1149 | ||
| 1150 | // Add key display values (2-3 additional fields beyond id/name) | ||
| 1151 | // Priority: status, type, date, amount, role, class fields | ||
| 1152 | def keyFieldPriority = [ | ||
| 1153 | 'statusId', 'status', 'productTypeEnumId', 'productAssocTypeEnumId', | ||
| 1154 | 'communicationEventTypeId', 'roleTypeId', 'partyClassificationId', | ||
| 1155 | 'orderPartStatusId', 'placedDate', 'entryDate', 'fromDate', 'thruDate', | ||
| 1156 | 'grandTotal', 'quantity', 'price', 'amount', | ||
| 1157 | 'username', 'emailAddress', 'fromPartyId', 'toPartyId' | ||
| 1158 | ] | ||
| 1159 | |||
| 1160 | def extraFields = [:] | ||
| 1161 | def extraCount = 0 | ||
| 1162 | for (fieldName in keyFieldPriority) { | ||
| 1163 | if (extraCount >= 3) break | ||
| 1164 | def value = row[fieldName] | ||
| 1165 | if (value != null && value != '' && value != id && value != name) { | ||
| 1166 | // Simplify enumId suffixes for display | ||
| 1167 | def displayKey = fieldName.replaceAll(/EnumId$/, '').replaceAll(/Id$/, '') | ||
| 1168 | extraFields[displayKey] = value.toString() | ||
| 1169 | extraCount++ | ||
| 1170 | } | ||
| 1171 | } | ||
| 1172 | |||
| 1173 | // If no priority fields found, add first 2-3 non-empty visible fields | ||
| 1174 | if (extraCount == 0) { | ||
| 1175 | for (fieldDef in formData.fields?.take(8)) { | ||
| 1176 | if (extraCount >= 3) break | ||
| 1177 | def fieldName = fieldDef.name | ||
| 1178 | if (fieldDef.type == 'hidden') continue | ||
| 1179 | if (fieldName in ['id', 'pseudoId', 'name', 'productId', 'partyId', 'submitButton']) continue | ||
| 1180 | def value = row[fieldName] | ||
| 1181 | if (value != null && value != '' && value != id && value != name) { | ||
| 1182 | extraFields[fieldName] = value.toString() | ||
| 1183 | extraCount++ | ||
| 1184 | } | ||
| 1185 | } | ||
| 1186 | } | ||
| 1187 | |||
| 1188 | if (extraFields) rowInfo.data = extraFields | ||
| 1189 | |||
| 1062 | // Find link for this row | 1190 | // Find link for this row |
| 1063 | def rowLinks = data.links?.findAll { link -> | 1191 | def rowLinks = data.links?.findAll { link -> |
| 1064 | link.path?.contains(id?.toString()) && link.type == "navigation" | 1192 | link.path?.contains(id?.toString()) && link.type == "navigation" |
| ... | @@ -1178,6 +1306,12 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1178,6 +1306,12 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1178 | } | 1306 | } |
| 1179 | } | 1307 | } |
| 1180 | 1308 | ||
| 1309 | // Set resolvedScreenDef if we successfully traversed the path | ||
| 1310 | if (reachedIndex >= 0 && currentSd && reachedIndex == (screenPathList.size() - 1)) { | ||
| 1311 | resolvedScreenDef = currentSd | ||
| 1312 | ec.logger.info("MCP Path Resolution: Resolved to screen '${resolvedScreenDef?.getScreenName()}' via literal path") | ||
| 1313 | } | ||
| 1314 | |||
| 1181 | // 2. If literal resolution failed, try Component-based resolution | 1315 | // 2. If literal resolution failed, try Component-based resolution |
| 1182 | if (reachedIndex == -1 && pathSegments.size() >= 2) { | 1316 | if (reachedIndex == -1 && pathSegments.size() >= 2) { |
| 1183 | def componentName = pathSegments[0] | 1317 | def componentName = pathSegments[0] |
| ... | @@ -1261,20 +1395,62 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1261,20 +1395,62 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1261 | def actionResult = [:] | 1395 | def actionResult = [:] |
| 1262 | 1396 | ||
| 1263 | if (action && resolvedScreenDef) { | 1397 | if (action && resolvedScreenDef) { |
| 1264 | // Verify the transition exists | 1398 | // Find the transition - must traverse default subscreens like discovery does |
| 1265 | def transition = resolvedScreenDef.getAllTransitions().find { it.getName() == action } | 1399 | // because transitions may be defined on default subscreens, not the parent screen |
| 1400 | def transition = null | ||
| 1401 | def transitionScreenDef = null | ||
| 1402 | def subscreenPath = "" // Track the path through default subscreens | ||
| 1403 | |||
| 1404 | def currentScreenDef = resolvedScreenDef | ||
| 1405 | def depth = 0 | ||
| 1406 | while (currentScreenDef && depth < 5 && !transition) { | ||
| 1407 | // Check this screen for the transition | ||
| 1408 | transition = currentScreenDef.getAllTransitions().find { it.getName() == action } | ||
| 1409 | if (transition) { | ||
| 1410 | transitionScreenDef = currentScreenDef | ||
| 1411 | ec.logger.info("MCP: Found transition '${action}' on screen '${currentScreenDef.getScreenName()}' at depth ${depth}") | ||
| 1412 | break | ||
| 1413 | } | ||
| 1414 | |||
| 1415 | // Check for default subscreen to continue traversal | ||
| 1416 | def defaultSubscreenName = currentScreenDef.getDefaultSubscreensItem() | ||
| 1417 | if (defaultSubscreenName) { | ||
| 1418 | def subscreenItem = currentScreenDef.getSubscreensItem(defaultSubscreenName) | ||
| 1419 | if (subscreenItem?.location) { | ||
| 1420 | try { | ||
| 1421 | def subscreenDef = currentScreenDef.sfi.getScreenDefinition(subscreenItem.location) | ||
| 1422 | if (subscreenDef) { | ||
| 1423 | subscreenPath += "/" + defaultSubscreenName | ||
| 1424 | currentScreenDef = subscreenDef | ||
| 1425 | depth++ | ||
| 1426 | } else { | ||
| 1427 | currentScreenDef = null | ||
| 1428 | } | ||
| 1429 | } catch (Exception e) { | ||
| 1430 | ec.logger.warn("MCP: Could not load subscreen for transition lookup: ${e.message}") | ||
| 1431 | currentScreenDef = null | ||
| 1432 | } | ||
| 1433 | } else { | ||
| 1434 | currentScreenDef = null | ||
| 1435 | } | ||
| 1436 | } else { | ||
| 1437 | currentScreenDef = null | ||
| 1438 | } | ||
| 1439 | } | ||
| 1440 | |||
| 1266 | if (transition) { | 1441 | if (transition) { |
| 1267 | // Append transition to path - framework will execute it via recursiveRunTransition | 1442 | // Append subscreen path and transition to the render path |
| 1268 | relativePath = "${testScreenPath}/${action}" | 1443 | // The framework needs the full path including default subscreens to find the transition |
| 1444 | relativePath = testScreenPath + subscreenPath + "/" + action | ||
| 1269 | def serviceName = transition.getSingleServiceName() | 1445 | def serviceName = transition.getSingleServiceName() |
| 1270 | actionResult = [ | 1446 | actionResult = [ |
| 1271 | action: action, | 1447 | action: action, |
| 1272 | service: serviceName, | 1448 | service: serviceName, |
| 1273 | status: "pending" // Will be updated after render based on errors | 1449 | status: "pending" // Will be updated after render based on errors |
| 1274 | ] | 1450 | ] |
| 1275 | ec.logger.info("MCP Screen Execution: Will execute transition '${action}' via framework (service: ${serviceName ?: 'none'})") | 1451 | ec.logger.info("MCP Screen Execution: Will execute transition '${action}' via framework path '${relativePath}' (service: ${serviceName ?: 'none'})") |
| 1276 | } else { | 1452 | } else { |
| 1277 | ec.logger.warn("MCP Screen Execution: Action '${action}' not found in screen transitions") | 1453 | ec.logger.warn("MCP Screen Execution: Action '${action}' not found in screen transitions (checked ${depth + 1} screens in hierarchy)") |
| 1278 | actionResult = [ | 1454 | actionResult = [ |
| 1279 | action: action, | 1455 | action: action, |
| 1280 | status: "error", | 1456 | status: "error", |
| ... | @@ -1300,9 +1476,52 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1300,9 +1476,52 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1300 | def errorMessages = testRender.getErrorMessages() | 1476 | def errorMessages = testRender.getErrorMessages() |
| 1301 | def hasError = errorMessages && errorMessages.size() > 0 | 1477 | def hasError = errorMessages && errorMessages.size() > 0 |
| 1302 | 1478 | ||
| 1479 | // Also check JSON response for validation errors (more structured) | ||
| 1480 | def jsonResponse = testRender.getJsonObject() | ||
| 1481 | def validationErrors = [] | ||
| 1482 | def jsonErrors = [] | ||
| 1483 | if (jsonResponse instanceof Map) { | ||
| 1484 | // Extract structured validation errors from JSON response | ||
| 1485 | if (jsonResponse.validationErrors) { | ||
| 1486 | validationErrors = jsonResponse.validationErrors.collect { ve -> | ||
| 1487 | // ValidationError.getMap() returns: form, field, serviceName, message | ||
| 1488 | [ | ||
| 1489 | field: ve.field ?: ve.fieldPretty, | ||
| 1490 | form: ve.form, | ||
| 1491 | service: ve.serviceName, | ||
| 1492 | message: ve.message ?: ve.messageWithFieldPretty ?: ve.toString() | ||
| 1493 | ] | ||
| 1494 | } | ||
| 1495 | hasError = true | ||
| 1496 | } | ||
| 1497 | // Also capture general errors from JSON | ||
| 1498 | if (jsonResponse.errors) { | ||
| 1499 | jsonErrors = jsonResponse.errors | ||
| 1500 | hasError = true | ||
| 1501 | } | ||
| 1502 | } | ||
| 1503 | |||
| 1303 | if (hasError) { | 1504 | if (hasError) { |
| 1304 | actionResult.status = "error" | 1505 | actionResult.status = "error" |
| 1305 | actionResult.message = errorMessages.join("; ") | 1506 | |
| 1507 | // Build comprehensive error message | ||
| 1508 | def allMessages = [] | ||
| 1509 | if (errorMessages) allMessages.addAll(errorMessages) | ||
| 1510 | if (jsonErrors) allMessages.addAll(jsonErrors) | ||
| 1511 | actionResult.message = allMessages.join("; ") ?: "Validation failed" | ||
| 1512 | |||
| 1513 | // Add structured validation errors for field-level feedback | ||
| 1514 | if (validationErrors) { | ||
| 1515 | actionResult.validationErrors = validationErrors | ||
| 1516 | // Also add field-specific summary | ||
| 1517 | def fieldSummary = validationErrors.collect { ve -> | ||
| 1518 | ve.field ? "${ve.field}: ${ve.message}" : ve.message | ||
| 1519 | }.join("; ") | ||
| 1520 | if (!actionResult.message || actionResult.message == "Validation failed") { | ||
| 1521 | actionResult.message = fieldSummary | ||
| 1522 | } | ||
| 1523 | } | ||
| 1524 | |||
| 1306 | ec.logger.error("MCP Screen Execution: Transition '${action}' completed with errors: ${actionResult.message}") | 1525 | ec.logger.error("MCP Screen Execution: Transition '${action}' completed with errors: ${actionResult.message}") |
| 1307 | } else { | 1526 | } else { |
| 1308 | actionResult.status = "executed" | 1527 | actionResult.status = "executed" |
| ... | @@ -1329,6 +1548,24 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1329,6 +1548,24 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1329 | } | 1548 | } |
| 1330 | } | 1549 | } |
| 1331 | } | 1550 | } |
| 1551 | |||
| 1552 | // Also check JSON response for result data | ||
| 1553 | if (jsonResponse instanceof Map) { | ||
| 1554 | // Copy any IDs from JSON response | ||
| 1555 | jsonResponse.each { key, value -> | ||
| 1556 | if (key.toString().endsWith('Id') && value && !key.toString().startsWith('_')) { | ||
| 1557 | def keyStr = key.toString() | ||
| 1558 | if (!['userId', 'username', 'sessionId', 'requestId'].contains(keyStr)) { | ||
| 1559 | result[keyStr] = value | ||
| 1560 | } | ||
| 1561 | } | ||
| 1562 | } | ||
| 1563 | // Copy messages if present | ||
| 1564 | if (jsonResponse.messages) { | ||
| 1565 | actionResult.messages = jsonResponse.messages | ||
| 1566 | } | ||
| 1567 | } | ||
| 1568 | |||
| 1332 | if (result) { | 1569 | if (result) { |
| 1333 | actionResult.result = result | 1570 | actionResult.result = result |
| 1334 | } | 1571 | } |
| ... | @@ -1339,8 +1576,22 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1339,8 +1576,22 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1339 | // --- Semantic State Extraction --- | 1576 | // --- Semantic State Extraction --- |
| 1340 | def semanticState = [:] | 1577 | def semanticState = [:] |
| 1341 | 1578 | ||
| 1342 | // Get final screen definition using resolved screen location | 1579 | // Get final screen definition - prefer the actual rendered screen from ScreenRender |
| 1580 | // This ensures we get the deepest screen in the path, not just the root | ||
| 1343 | def finalScreenDef = resolvedScreenDef | 1581 | def finalScreenDef = resolvedScreenDef |
| 1582 | try { | ||
| 1583 | def screenRender = testRender.getScreenRender() | ||
| 1584 | if (screenRender?.screenUrlInfo?.screenPathDefList) { | ||
| 1585 | def pathDefList = screenRender.screenUrlInfo.screenPathDefList | ||
| 1586 | if (pathDefList.size() > 0) { | ||
| 1587 | // Get the last (deepest) screen in the path | ||
| 1588 | finalScreenDef = pathDefList.get(pathDefList.size() - 1) | ||
| 1589 | ec.logger.info("MCP: Using actual rendered screen '${finalScreenDef?.getScreenName()}' from screenPathDefList (${pathDefList.size()} screens in path)") | ||
| 1590 | } | ||
| 1591 | } | ||
| 1592 | } catch (Exception e) { | ||
| 1593 | ec.logger.warn("MCP: Could not get screen from ScreenRender, using resolved: ${e.message}") | ||
| 1594 | } | ||
| 1344 | 1595 | ||
| 1345 | if (finalScreenDef && postContext) { | 1596 | if (finalScreenDef && postContext) { |
| 1346 | semanticState.screenPath = inputScreenPath | 1597 | semanticState.screenPath = inputScreenPath |
| ... | @@ -1355,30 +1606,72 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1355,30 +1606,72 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1355 | } | 1606 | } |
| 1356 | 1607 | ||
| 1357 | // Extract transitions (Actions) with type classification and metadata | 1608 | // Extract transitions (Actions) with type classification and metadata |
| 1609 | // Collect from current screen AND active subscreens (iterative traversal) | ||
| 1358 | semanticState.actions = [] | 1610 | semanticState.actions = [] |
| 1359 | finalScreenDef.getAllTransitions().each { trans -> | 1611 | def collectedTransitionNames = [] as Set |
| 1360 | def transName = trans.getName() | 1612 | |
| 1361 | def service = trans.getSingleServiceName() | 1613 | // Build list of screens to process (current + default subscreens chain) |
| 1614 | def screensToProcess = [] | ||
| 1615 | def currentScreenDef = finalScreenDef | ||
| 1616 | def depth = 0 | ||
| 1617 | while (currentScreenDef && depth < 5) { | ||
| 1618 | screensToProcess << currentScreenDef | ||
| 1362 | 1619 | ||
| 1363 | // Classify action type | 1620 | // Check for default subscreen |
| 1364 | def actionType = "screen-transition" | 1621 | def defaultSubscreenName = currentScreenDef.getDefaultSubscreensItem() |
| 1365 | def transNameLower = transName?.toString()?.toLowerCase() ?: '' | 1622 | if (defaultSubscreenName) { |
| 1366 | 1623 | def subscreenItem = currentScreenDef.getSubscreensItem(defaultSubscreenName) | |
| 1367 | if (service) { | 1624 | if (subscreenItem?.location) { |
| 1368 | actionType = "service-action" | 1625 | try { |
| 1369 | } else if (transNameLower.contains('delete')) { | 1626 | def subscreenDef = currentScreenDef.sfi.getScreenDefinition(subscreenItem.location) |
| 1370 | actionType = "delete-action" | 1627 | if (subscreenDef) { |
| 1371 | } else if (transNameLower.startsWith('form') || transNameLower == 'find' || transNameLower == 'search') { | 1628 | ec.logger.info("MCP: Adding default subscreen '${defaultSubscreenName}' at ${subscreenItem.location} to traversal") |
| 1372 | actionType = "form-action" | 1629 | currentScreenDef = subscreenDef |
| 1630 | depth++ | ||
| 1631 | } else { | ||
| 1632 | currentScreenDef = null | ||
| 1633 | } | ||
| 1634 | } catch (Exception e) { | ||
| 1635 | ec.logger.warn("MCP: Could not load subscreen ${subscreenItem.location}: ${e.message}") | ||
| 1636 | currentScreenDef = null | ||
| 1637 | } | ||
| 1638 | } else { | ||
| 1639 | currentScreenDef = null | ||
| 1640 | } | ||
| 1641 | } else { | ||
| 1642 | currentScreenDef = null | ||
| 1643 | } | ||
| 1644 | } | ||
| 1645 | |||
| 1646 | // Now collect transitions from all screens | ||
| 1647 | screensToProcess.each { screenDef -> | ||
| 1648 | screenDef.getAllTransitions().each { trans -> | ||
| 1649 | def transName = trans.getName() | ||
| 1650 | if (collectedTransitionNames.contains(transName)) return // skip duplicates | ||
| 1651 | collectedTransitionNames.add(transName) | ||
| 1652 | |||
| 1653 | def service = trans.getSingleServiceName() | ||
| 1654 | |||
| 1655 | // Classify action type | ||
| 1656 | def actionType = "screen-transition" | ||
| 1657 | def transNameLower = transName?.toString()?.toLowerCase() ?: '' | ||
| 1658 | |||
| 1659 | if (service) { | ||
| 1660 | actionType = "service-action" | ||
| 1661 | } else if (transNameLower.contains('delete')) { | ||
| 1662 | actionType = "delete-action" | ||
| 1663 | } else if (transNameLower.startsWith('form') || transNameLower == 'find' || transNameLower == 'search') { | ||
| 1664 | actionType = "form-action" | ||
| 1665 | } | ||
| 1666 | |||
| 1667 | def actionInfo = [ | ||
| 1668 | name: transName, | ||
| 1669 | service: service, | ||
| 1670 | type: actionType | ||
| 1671 | ] | ||
| 1672 | |||
| 1673 | semanticState.actions << actionInfo | ||
| 1373 | } | 1674 | } |
| 1374 | |||
| 1375 | def actionInfo = [ | ||
| 1376 | name: transName, | ||
| 1377 | service: service, | ||
| 1378 | type: actionType | ||
| 1379 | ] | ||
| 1380 | |||
| 1381 | semanticState.actions << actionInfo | ||
| 1382 | } | 1675 | } |
| 1383 | 1676 | ||
| 1384 | // 3. Extract parameters with metadata | 1677 | // 3. Extract parameters with metadata |
| ... | @@ -2236,7 +2529,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -2236,7 +2529,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 2236 | </service> | 2529 | </service> |
| 2237 | 2530 | ||
| 2238 | <service verb="mcp" noun="SearchScreens" authenticate="false" allow-remote="true" transaction-timeout="60"> | 2531 | <service verb="mcp" noun="SearchScreens" authenticate="false" allow-remote="true" transaction-timeout="60"> |
| 2239 | <description>Search for screens by name or path.</description> | 2532 | <description>Search for screens by name, path, or description. Builds an index by walking the screen tree.</description> |
| 2240 | <in-parameters> | 2533 | <in-parameters> |
| 2241 | <parameter name="query" required="true"/> | 2534 | <parameter name="query" required="true"/> |
| 2242 | <parameter name="sessionId"/> | 2535 | <parameter name="sessionId"/> |
| ... | @@ -2250,39 +2543,178 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -2250,39 +2543,178 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 2250 | 2543 | ||
| 2251 | ExecutionContext ec = context.ec | 2544 | ExecutionContext ec = context.ec |
| 2252 | def matches = [] | 2545 | def matches = [] |
| 2546 | def queryLower = query.toLowerCase() | ||
| 2253 | 2547 | ||
| 2254 | // Helper to convert full component path to simple forward-slash path | 2548 | // Screen index cache key - use Moqui's cache API properly |
| 2255 | def convertToSimplePath = { fullPath -> | 2549 | def cacheKey = "MCP_SCREEN_INDEX" |
| 2256 | if (!fullPath) return null | 2550 | def mcpCache = ec.cache.getCache("mcp.screen.index") |
| 2257 | String cleanPath = fullPath | 2551 | def screenIndex = mcpCache.get(cacheKey) |
| 2258 | if (cleanPath.startsWith("component://")) cleanPath = cleanPath.substring(12) | 2552 | |
| 2259 | if (cleanPath.endsWith(".xml")) cleanPath = cleanPath.substring(0, cleanPath.length() - 4) | 2553 | if (!screenIndex) { |
| 2260 | List<String> parts = cleanPath.split('/').toList() | 2554 | ec.logger.info("SearchScreens: Building screen index...") |
| 2261 | if (parts.size() > 1 && parts[1] == "screen") parts.remove(1) | 2555 | screenIndex = [] |
| 2262 | return parts.join('/') | 2556 | |
| 2557 | // Helper to load wiki description for a path | ||
| 2558 | def loadWikiDescription = { simplePath -> | ||
| 2559 | try { | ||
| 2560 | def wikiPage = ec.entity.find("moqui.resource.wiki.WikiPage") | ||
| 2561 | .condition("wikiSpaceId", "MCP_SCREEN_DOCS") | ||
| 2562 | .condition("pagePath", simplePath) | ||
| 2563 | .useCache(true) | ||
| 2564 | .one() | ||
| 2565 | |||
| 2566 | if (wikiPage) { | ||
| 2567 | def dbResource = ec.entity.find("moqui.resource.DbResource") | ||
| 2568 | .condition("parentResourceId", "WIKI_MCP_SCREEN_DOCS") | ||
| 2569 | .condition("filename", wikiPage.pagePath + ".md") | ||
| 2570 | .one() | ||
| 2571 | |||
| 2572 | if (dbResource) { | ||
| 2573 | def dbResourceFile = ec.entity.find("moqui.resource.DbResourceFile") | ||
| 2574 | .condition("resourceId", dbResource.resourceId) | ||
| 2575 | .one() | ||
| 2576 | |||
| 2577 | if (dbResourceFile && dbResourceFile.fileData) { | ||
| 2578 | def content = new String(dbResourceFile.fileData.getBytes(1L, dbResourceFile.fileData.length() as int), "UTF-8") | ||
| 2579 | // Extract first line or sentence as description | ||
| 2580 | def lines = content.split('\n') | ||
| 2581 | for (line in lines) { | ||
| 2582 | line = line.trim() | ||
| 2583 | if (line && !line.startsWith('#')) { | ||
| 2584 | return line.length() > 150 ? line.substring(0, 147) + "..." : line | ||
| 2585 | } | ||
| 2586 | } | ||
| 2587 | } | ||
| 2588 | } | ||
| 2589 | } | ||
| 2590 | } catch (Exception e) { | ||
| 2591 | ec.logger.debug("SearchScreens: Error loading wiki for ${simplePath}: ${e.message}") | ||
| 2592 | } | ||
| 2593 | return null | ||
| 2594 | } | ||
| 2595 | |||
| 2596 | // Recursive function to walk screen tree | ||
| 2597 | def walkScreenTree | ||
| 2598 | walkScreenTree = { screenDef, basePath, depth -> | ||
| 2599 | if (depth > 6 || !screenDef) return // Limit depth to prevent infinite loops | ||
| 2600 | |||
| 2601 | try { | ||
| 2602 | screenDef.getSubscreensItemsSorted().each { subItem -> | ||
| 2603 | def subName = subItem.getName() | ||
| 2604 | def subPath = basePath ? "${basePath}/${subName}" : subName | ||
| 2605 | def subLocation = subItem.getLocation() | ||
| 2606 | |||
| 2607 | if (subLocation) { | ||
| 2608 | // Get wiki description or generate from name | ||
| 2609 | def description = loadWikiDescription(subPath) | ||
| 2610 | if (!description) { | ||
| 2611 | // Generate description from screen name | ||
| 2612 | def humanName = subName.replaceAll(/([A-Z])/, ' $1').trim() | ||
| 2613 | description = "Screen: ${humanName}" | ||
| 2614 | } | ||
| 2615 | |||
| 2616 | // Extract screen name for search | ||
| 2617 | def screenName = subName | ||
| 2618 | |||
| 2619 | screenIndex << [ | ||
| 2620 | path: subPath, | ||
| 2621 | name: screenName, | ||
| 2622 | description: description, | ||
| 2623 | depth: depth | ||
| 2624 | ] | ||
| 2625 | |||
| 2626 | // Recurse into subscreen | ||
| 2627 | try { | ||
| 2628 | def subScreenDef = ec.screen.getScreenDefinition(subLocation) | ||
| 2629 | if (subScreenDef) { | ||
| 2630 | walkScreenTree(subScreenDef, subPath, depth + 1) | ||
| 2631 | } | ||
| 2632 | } catch (Exception e) { | ||
| 2633 | ec.logger.debug("SearchScreens: Could not load subscreen ${subPath}: ${e.message}") | ||
| 2634 | } | ||
| 2635 | } | ||
| 2636 | } | ||
| 2637 | } catch (Exception e) { | ||
| 2638 | ec.logger.debug("SearchScreens: Error walking ${basePath}: ${e.message}") | ||
| 2639 | } | ||
| 2640 | } | ||
| 2641 | |||
| 2642 | // Start from webroot | ||
| 2643 | def webrootSd = ec.screen.getScreenDefinition("component://webroot/screen/webroot.xml") | ||
| 2644 | if (webrootSd) { | ||
| 2645 | walkScreenTree(webrootSd, "", 0) | ||
| 2646 | } | ||
| 2647 | |||
| 2648 | // Cache the index | ||
| 2649 | mcpCache.put(cacheKey, screenIndex) | ||
| 2650 | ec.logger.info("SearchScreens: Built index with ${screenIndex.size()} screens") | ||
| 2263 | } | 2651 | } |
| 2264 | |||
| 2265 | // Search all screens known to the system | ||
| 2266 | def aacvList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") | ||
| 2267 | .condition("artifactTypeEnumId", "AT_XML_SCREEN") | ||
| 2268 | .condition("artifactName", "like", "%${query}%") | ||
| 2269 | .selectField("artifactName") | ||
| 2270 | .distinct(true) | ||
| 2271 | .disableAuthz() | ||
| 2272 | .limit(20) | ||
| 2273 | .list() | ||
| 2274 | 2652 | ||
| 2275 | for (hit in aacvList) { | 2653 | // Search the index |
| 2276 | def simplePath = convertToSimplePath(hit.artifactName) | 2654 | def scored = [] |
| 2277 | if (simplePath) { | 2655 | for (screen in screenIndex) { |
| 2278 | matches << [ | 2656 | def score = 0 |
| 2279 | path: simplePath, | 2657 | def nameLower = screen.name?.toLowerCase() ?: "" |
| 2280 | description: "Screen: ${hit.artifactName}" | 2658 | def pathLower = screen.path?.toLowerCase() ?: "" |
| 2281 | ] | 2659 | def descLower = screen.description?.toLowerCase() ?: "" |
| 2660 | |||
| 2661 | // Exact name match (highest priority) | ||
| 2662 | if (nameLower == queryLower) { | ||
| 2663 | score += 100 | ||
| 2664 | } | ||
| 2665 | // Name starts with query | ||
| 2666 | else if (nameLower.startsWith(queryLower)) { | ||
| 2667 | score += 50 | ||
| 2282 | } | 2668 | } |
| 2669 | // Name contains query | ||
| 2670 | else if (nameLower.contains(queryLower)) { | ||
| 2671 | score += 30 | ||
| 2672 | } | ||
| 2673 | |||
| 2674 | // Path contains query | ||
| 2675 | if (pathLower.contains(queryLower)) { | ||
| 2676 | score += 20 | ||
| 2677 | } | ||
| 2678 | |||
| 2679 | // Description contains query | ||
| 2680 | if (descLower.contains(queryLower)) { | ||
| 2681 | score += 10 | ||
| 2682 | } | ||
| 2683 | |||
| 2684 | // Prefer shallower screens (more likely to be entry points) | ||
| 2685 | if (score > 0) { | ||
| 2686 | score -= (screen.depth ?: 0) * 2 | ||
| 2687 | scored << [screen: screen, score: score] | ||
| 2688 | } | ||
| 2689 | } | ||
| 2690 | |||
| 2691 | // Sort by score descending, take top 15 | ||
| 2692 | scored.sort { -it.score } | ||
| 2693 | def topMatches = scored.take(15) | ||
| 2694 | |||
| 2695 | for (item in topMatches) { | ||
| 2696 | matches << [ | ||
| 2697 | path: item.screen.path, | ||
| 2698 | name: item.screen.name, | ||
| 2699 | description: item.screen.description | ||
| 2700 | ] | ||
| 2283 | } | 2701 | } |
| 2284 | 2702 | ||
| 2285 | result = [matches: matches] | 2703 | // Add hint if no matches |
| 2704 | def hint = null | ||
| 2705 | if (matches.isEmpty()) { | ||
| 2706 | hint = "No screens found matching '${query}'. Try broader terms like 'product', 'order', 'party', or use moqui_browse_screens to explore." | ||
| 2707 | } else if (matches.size() >= 15) { | ||
| 2708 | hint = "Showing top 15 results. Refine your search for more specific matches." | ||
| 2709 | } | ||
| 2710 | |||
| 2711 | def resultMap = hint ? [matches: matches, hint: hint] : [matches: matches] | ||
| 2712 | |||
| 2713 | // Return in MCP format - content array with JSON text | ||
| 2714 | result = [ | ||
| 2715 | content: [[type: "text", text: new groovy.json.JsonBuilder(resultMap).toString()]], | ||
| 2716 | isError: false | ||
| 2717 | ] | ||
| 2286 | ]]></script> | 2718 | ]]></script> |
| 2287 | </actions> | 2719 | </actions> |
| 2288 | </service> | 2720 | </service> |
| ... | @@ -2343,6 +2775,45 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -2343,6 +2775,45 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 2343 | required: ["path"] | 2775 | required: ["path"] |
| 2344 | ] | 2776 | ] |
| 2345 | ], | 2777 | ], |
| 2778 | [ | ||
| 2779 | name: "moqui_get_help", | ||
| 2780 | title: "Get Help", | ||
| 2781 | description: "Fetch extended documentation for a screen or service. Use URIs from 'describedby' fields in ARIA responses.", | ||
| 2782 | inputSchema: [ | ||
| 2783 | type: "object", | ||
| 2784 | properties: [ | ||
| 2785 | "uri": [type: "string", description: "Help URI (e.g., 'wiki:screen:EditProduct' or 'wiki:service:ProductFeature')"] | ||
| 2786 | ], | ||
| 2787 | required: ["uri"] | ||
| 2788 | ] | ||
| 2789 | ], | ||
| 2790 | [ | ||
| 2791 | name: "moqui_batch_operations", | ||
| 2792 | title: "Batch Operations", | ||
| 2793 | description: "Execute multiple screen operations in sequence. Stops on first error. Returns results for each operation.", | ||
| 2794 | inputSchema: [ | ||
| 2795 | type: "object", | ||
| 2796 | properties: [ | ||
| 2797 | "operations": [ | ||
| 2798 | type: "array", | ||
| 2799 | description: "Array of operations to execute in sequence", | ||
| 2800 | items: [ | ||
| 2801 | type: "object", | ||
| 2802 | properties: [ | ||
| 2803 | "id": [type: "string", description: "Optional operation identifier for result tracking"], | ||
| 2804 | "path": [type: "string", description: "Screen path"], | ||
| 2805 | "action": [type: "string", description: "Action/transition to execute"], | ||
| 2806 | "parameters": [type: "object", description: "Parameters for the action"] | ||
| 2807 | ], | ||
| 2808 | required: ["path", "action"] | ||
| 2809 | ] | ||
| 2810 | ], | ||
| 2811 | "stopOnError": [type: "boolean", description: "Stop execution on first error (default: true)"], | ||
| 2812 | "returnLastOnly": [type: "boolean", description: "Return only last operation result (default: false)"] | ||
| 2813 | ], | ||
| 2814 | required: ["operations"] | ||
| 2815 | ] | ||
| 2816 | ], | ||
| 2346 | [ | 2817 | [ |
| 2347 | name: "prompts_list", | 2818 | name: "prompts_list", |
| 2348 | title: "List Prompts", | 2819 | title: "List Prompts", |
| ... | @@ -2402,6 +2873,270 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -2402,6 +2873,270 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 2402 | </actions> | 2873 | </actions> |
| 2403 | </service> | 2874 | </service> |
| 2404 | 2875 | ||
| 2876 | <service verb="mcp" noun="GetHelp" authenticate="true" allow-remote="true" transaction-timeout="30"> | ||
| 2877 | <description>Fetch extended documentation for a screen or service. Use URIs from 'describedby' fields in ARIA responses.</description> | ||
| 2878 | <in-parameters> | ||
| 2879 | <parameter name="uri" required="true"><description>Help URI (e.g., 'wiki:screen:EditProduct' or 'wiki:service:ProductFeature')</description></parameter> | ||
| 2880 | </in-parameters> | ||
| 2881 | <out-parameters> | ||
| 2882 | <parameter name="result" type="Map"/> | ||
| 2883 | </out-parameters> | ||
| 2884 | <actions> | ||
| 2885 | <script><![CDATA[ | ||
| 2886 | import org.moqui.context.ExecutionContext | ||
| 2887 | import groovy.json.JsonBuilder | ||
| 2888 | |||
| 2889 | ExecutionContext ec = context.ec | ||
| 2890 | |||
| 2891 | def content = null | ||
| 2892 | def metadata = [uri: uri] | ||
| 2893 | |||
| 2894 | // Parse URI format: wiki:type:name | ||
| 2895 | // e.g., wiki:screen:EditProduct, wiki:service:ProductFeature | ||
| 2896 | if (uri?.startsWith("wiki:")) { | ||
| 2897 | def parts = uri.split(":", 3) | ||
| 2898 | if (parts.length >= 3) { | ||
| 2899 | def wikiType = parts[1] // screen, service | ||
| 2900 | def pageName = parts[2] | ||
| 2901 | |||
| 2902 | metadata.type = wikiType | ||
| 2903 | metadata.name = pageName | ||
| 2904 | |||
| 2905 | // Determine wiki space based on type | ||
| 2906 | def wikiSpaceId = null | ||
| 2907 | def pagePath = null | ||
| 2908 | |||
| 2909 | switch (wikiType) { | ||
| 2910 | case "screen": | ||
| 2911 | wikiSpaceId = "MCP_SCREEN_DOCS" | ||
| 2912 | // Try to find by screen name in page path | ||
| 2913 | pagePath = pageName | ||
| 2914 | break | ||
| 2915 | case "service": | ||
| 2916 | wikiSpaceId = "MCP_SERVICE_DOCS" | ||
| 2917 | pagePath = pageName | ||
| 2918 | break | ||
| 2919 | default: | ||
| 2920 | wikiSpaceId = "MCP_SCREEN_DOCS" | ||
| 2921 | pagePath = pageName | ||
| 2922 | } | ||
| 2923 | |||
| 2924 | // Try to find wiki page by path | ||
| 2925 | def wikiPage = ec.entity.find("moqui.resource.wiki.WikiPage") | ||
| 2926 | .condition("wikiSpaceId", wikiSpaceId) | ||
| 2927 | .condition("pagePath", pagePath) | ||
| 2928 | .useCache(true) | ||
| 2929 | .one() | ||
| 2930 | |||
| 2931 | // If not found by exact path, try partial match | ||
| 2932 | if (!wikiPage) { | ||
| 2933 | def allPages = ec.entity.find("moqui.resource.wiki.WikiPage") | ||
| 2934 | .condition("wikiSpaceId", wikiSpaceId) | ||
| 2935 | .useCache(true) | ||
| 2936 | .list() | ||
| 2937 | wikiPage = allPages.find { it.pagePath?.endsWith(pagePath) || it.pagePath?.contains(pagePath) } | ||
| 2938 | } | ||
| 2939 | |||
| 2940 | if (wikiPage) { | ||
| 2941 | // Use direct DbResource/DbResourceFile lookup (like BrowseScreens does) | ||
| 2942 | def parentResourceId = (wikiType == "service") ? "WIKI_MCP_SERVICE_DOCS" : "WIKI_MCP_SCREEN_DOCS" | ||
| 2943 | |||
| 2944 | def dbResource = ec.entity.find("moqui.resource.DbResource") | ||
| 2945 | .condition("parentResourceId", parentResourceId) | ||
| 2946 | .condition("filename", wikiPage.pagePath + ".md") | ||
| 2947 | .one() | ||
| 2948 | |||
| 2949 | |||
| 2950 | |||
| 2951 | if (dbResource) { | ||
| 2952 | def dbResourceFile = ec.entity.find("moqui.resource.DbResourceFile") | ||
| 2953 | .condition("resourceId", dbResource.resourceId) | ||
| 2954 | .condition("versionName", wikiPage.publishedVersionName) | ||
| 2955 | .one() | ||
| 2956 | |||
| 2957 | if (!dbResourceFile) { | ||
| 2958 | dbResourceFile = ec.entity.find("moqui.resource.DbResourceFile") | ||
| 2959 | .condition("resourceId", dbResource.resourceId) | ||
| 2960 | .one() | ||
| 2961 | } | ||
| 2962 | |||
| 2963 | if (dbResourceFile && dbResourceFile.fileData) { | ||
| 2964 | content = new String(dbResourceFile.fileData.getBytes(new Long(1).longValue(), new Long(dbResourceFile.fileData.length()).intValue()), "UTF-8") | ||
| 2965 | metadata.found = true | ||
| 2966 | metadata.pagePath = wikiPage.pagePath | ||
| 2967 | metadata.resourceId = dbResource.resourceId | ||
| 2968 | } | ||
| 2969 | } | ||
| 2970 | } | ||
| 2971 | |||
| 2972 | if (!content) { | ||
| 2973 | metadata.found = false | ||
| 2974 | content = "No documentation found for ${wikiType}: ${pageName}. Available documentation can be found in the MCP_SCREEN_DOCS and MCP_SERVICE_DOCS wiki spaces." | ||
| 2975 | } | ||
| 2976 | } | ||
| 2977 | } else { | ||
| 2978 | metadata.error = "Invalid URI format. Expected 'wiki:type:name' (e.g., 'wiki:service:ProductFeature')" | ||
| 2979 | content = metadata.error | ||
| 2980 | } | ||
| 2981 | |||
| 2982 | def resultData = [ | ||
| 2983 | content: content, | ||
| 2984 | metadata: metadata | ||
| 2985 | ] | ||
| 2986 | |||
| 2987 | result = [ | ||
| 2988 | content: [[type: "text", text: new JsonBuilder(resultData).toString()]], | ||
| 2989 | isError: false | ||
| 2990 | ] | ||
| 2991 | ]]></script> | ||
| 2992 | </actions> | ||
| 2993 | </service> | ||
| 2994 | |||
| 2405 | <!-- NOTE: handle#McpRequest service removed - functionality moved to screen/webapp.xml for unified handling --> | 2995 | <!-- NOTE: handle#McpRequest service removed - functionality moved to screen/webapp.xml for unified handling --> |
| 2406 | 2996 | ||
| 2997 | <service verb="mcp" noun="BatchOperations" authenticate="true" allow-remote="true" transaction-timeout="120"> | ||
| 2998 | <description>Execute multiple screen operations in sequence. Stops on first error by default. Returns results for each operation.</description> | ||
| 2999 | <in-parameters> | ||
| 3000 | <parameter name="operations" type="List" required="true"><description>Array of operations to execute in sequence</description></parameter> | ||
| 3001 | <parameter name="stopOnError" type="Boolean" default="true"><description>Stop execution on first error (default: true)</description></parameter> | ||
| 3002 | <parameter name="returnLastOnly" type="Boolean" default="false"><description>Return only last operation result (default: false)</description></parameter> | ||
| 3003 | </in-parameters> | ||
| 3004 | <out-parameters> | ||
| 3005 | <parameter name="result" type="Map"/> | ||
| 3006 | </out-parameters> | ||
| 3007 | <actions> | ||
| 3008 | <script><![CDATA[ | ||
| 3009 | import org.moqui.context.ExecutionContext | ||
| 3010 | import groovy.json.JsonBuilder | ||
| 3011 | import groovy.json.JsonSlurper | ||
| 3012 | |||
| 3013 | ExecutionContext ec = context.ec | ||
| 3014 | |||
| 3015 | def results = [] | ||
| 3016 | def hasError = false | ||
| 3017 | def lastResult = null | ||
| 3018 | def executedCount = 0 | ||
| 3019 | |||
| 3020 | ec.logger.info("BatchOperations: Starting batch with ${operations?.size()} operations, stopOnError=${stopOnError}") | ||
| 3021 | |||
| 3022 | for (int i = 0; i < operations.size(); i++) { | ||
| 3023 | def op = operations[i] | ||
| 3024 | def opId = op.id ?: "op_${i + 1}" | ||
| 3025 | |||
| 3026 | ec.logger.info("BatchOperations: Executing operation ${opId}: path=${op.path}, action=${op.action}") | ||
| 3027 | |||
| 3028 | try { | ||
| 3029 | // Call browse screens for each operation | ||
| 3030 | def browseResult = ec.service.sync().name("McpServices.mcp#BrowseScreens") | ||
| 3031 | .parameters([ | ||
| 3032 | path: op.path, | ||
| 3033 | action: op.action, | ||
| 3034 | parameters: op.parameters ?: [:], | ||
| 3035 | renderMode: "compact" // Use compact mode for efficiency | ||
| 3036 | ]) | ||
| 3037 | .call() | ||
| 3038 | |||
| 3039 | // Extract the result content | ||
| 3040 | def opResult = [ | ||
| 3041 | id: opId, | ||
| 3042 | path: op.path, | ||
| 3043 | action: op.action, | ||
| 3044 | status: "success" | ||
| 3045 | ] | ||
| 3046 | |||
| 3047 | // Parse the result to check for errors | ||
| 3048 | if (browseResult?.result?.content) { | ||
| 3049 | def contentList = browseResult.result.content | ||
| 3050 | if (contentList && contentList.size() > 0) { | ||
| 3051 | def rawText = contentList[0].text | ||
| 3052 | if (rawText && rawText.startsWith("{")) { | ||
| 3053 | try { | ||
| 3054 | def parsed = new JsonSlurper().parseText(rawText) | ||
| 3055 | |||
| 3056 | // Check if there's an action result with error | ||
| 3057 | if (parsed.result?.status == "error" || parsed.actionResult?.status == "error") { | ||
| 3058 | opResult.status = "error" | ||
| 3059 | opResult.message = parsed.result?.message ?: parsed.actionResult?.message | ||
| 3060 | opResult.validationErrors = parsed.result?.validationErrors ?: parsed.actionResult?.validationErrors | ||
| 3061 | hasError = true | ||
| 3062 | } else { | ||
| 3063 | // Success - extract relevant data | ||
| 3064 | opResult.result = parsed.result ?: parsed.actionResult | ||
| 3065 | if (parsed.result?.result) { | ||
| 3066 | // Copy any IDs for use in subsequent operations | ||
| 3067 | opResult.outputIds = parsed.result.result | ||
| 3068 | } | ||
| 3069 | } | ||
| 3070 | } catch (e) { | ||
| 3071 | // JSON parse failed, treat as success but no parsed data | ||
| 3072 | opResult.rawOutput = rawText | ||
| 3073 | } | ||
| 3074 | } | ||
| 3075 | } | ||
| 3076 | } | ||
| 3077 | |||
| 3078 | executedCount++ | ||
| 3079 | lastResult = opResult | ||
| 3080 | results << opResult | ||
| 3081 | |||
| 3082 | ec.logger.info("BatchOperations: Operation ${opId} completed with status=${opResult.status}") | ||
| 3083 | |||
| 3084 | // Stop on error if requested | ||
| 3085 | if (hasError && stopOnError) { | ||
| 3086 | ec.logger.info("BatchOperations: Stopping batch due to error in operation ${opId}") | ||
| 3087 | break | ||
| 3088 | } | ||
| 3089 | |||
| 3090 | } catch (Exception e) { | ||
| 3091 | def opResult = [ | ||
| 3092 | id: opId, | ||
| 3093 | path: op.path, | ||
| 3094 | action: op.action, | ||
| 3095 | status: "error", | ||
| 3096 | message: "Exception: ${e.message}" | ||
| 3097 | ] | ||
| 3098 | results << opResult | ||
| 3099 | lastResult = opResult | ||
| 3100 | hasError = true | ||
| 3101 | executedCount++ | ||
| 3102 | |||
| 3103 | ec.logger.error("BatchOperations: Exception in operation ${opId}: ${e.message}", e) | ||
| 3104 | |||
| 3105 | if (stopOnError) { | ||
| 3106 | break | ||
| 3107 | } | ||
| 3108 | } | ||
| 3109 | } | ||
| 3110 | |||
| 3111 | def summary = [ | ||
| 3112 | totalOperations: operations.size(), | ||
| 3113 | executedOperations: executedCount, | ||
| 3114 | successCount: results.count { it.status == "success" }, | ||
| 3115 | errorCount: results.count { it.status == "error" }, | ||
| 3116 | hasError: hasError | ||
| 3117 | ] | ||
| 3118 | |||
| 3119 | def resultData | ||
| 3120 | if (returnLastOnly) { | ||
| 3121 | resultData = [ | ||
| 3122 | summary: summary, | ||
| 3123 | result: lastResult | ||
| 3124 | ] | ||
| 3125 | } else { | ||
| 3126 | resultData = [ | ||
| 3127 | summary: summary, | ||
| 3128 | results: results | ||
| 3129 | ] | ||
| 3130 | } | ||
| 3131 | |||
| 3132 | ec.logger.info("BatchOperations: Completed. Summary: ${summary}") | ||
| 3133 | |||
| 3134 | result = [ | ||
| 3135 | content: [[type: "text", text: new JsonBuilder(resultData).toString()]], | ||
| 3136 | isError: hasError | ||
| 3137 | ] | ||
| 3138 | ]]></script> | ||
| 3139 | </actions> | ||
| 3140 | </service> | ||
| 3141 | |||
| 2407 | </services> | 3142 | </services> |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
-
Please register or sign in to post a comment