e41ccca9 by Ean Schuessler

WIP: Add SDK-based MCP servlet implementations with SSE support

- Add multiple servlet implementations (EnhancedMcpServlet, ServiceBasedMcpServlet, MoquiMcpServlet)
- Implement SSE servlet support with proper content-type handling
- Add MCP filter for request processing
- Add web.xml configuration for servlet deployment
- Include SDK framework JAR and configuration files
- Remove old screen-based MCP implementation
- Update component configuration for new servlet-based approach
1 parent c65f866b
No preview for this file type
No preview for this file type
No preview for this file type
File mode changed
1 #Fri Nov 14 19:52:12 CST 2025
2 gradle.version=8.9
1 arguments=--init-script /home/ean/.config/Code/User/globalStorage/redhat.java/1.46.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle --init-script /home/ean/.config/Code/User/globalStorage/redhat.java/1.46.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/protobuf/init.gradle
2 auto.sync=false
3 build.scans.enabled=false
4 connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
5 connection.project.dir=../../..
6 eclipse.preferences.version=1
7 gradle.user.home=
8 java.home=/usr/lib/jvm/java-17-openjdk-amd64
9 jvm.arguments=
10 offline.mode=false
11 override.workspace.settings=true
12 show.console.view=true
13 show.executions.view=true
1 # Moqui MCP SDK Services Servlet
2
3 This document describes the MCP servlet implementation that combines the official Java MCP SDK with existing Moqui services for optimal architecture.
4
5 ## Overview
6
7 The `McpSdkServicesServlet` provides the best of both worlds:
8
9 - **MCP SDK** for standards-compliant protocol handling and SSE transport
10 - **McpServices.xml** for proven business logic and Moqui integration
11 - **Delegation pattern** for clean separation of concerns
12 - **Service reuse** across different MCP transport implementations
13
14 ## Architecture
15
16 ### Component Interaction
17
18 ```
19 ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
20 │ MCP Client │───▶│ MCP SDK Servlet │───▶│ McpServices.xml│
21 │ │ │ │ │ │
22 │ - JSON-RPC 2.0 │ │ - Transport │ │ - Business │
23 │ - SSE Events │ │ - Protocol │ │ Logic │
24 │ - Tool Calls │ │ - Validation │ │ - Security │
25 └─────────────────┘ └──────────────────┘ └─────────────────┘
26
27
28 ┌─────────────────┐
29 │ Moqui Framework│
30 │ │
31 │ - Entity Engine │
32 │ - Service Engine│
33 │ - Security │
34 └─────────────────┘
35 ```
36
37 ### Service Delegation Pattern
38
39 | MCP Method | SDK Servlet | Moqui Service | Description |
40 |------------|-------------|---------------|-------------|
41 | `initialize` | `initialize` tool | `mcp#Initialize` | Session initialization |
42 | `ping` | `ping` tool | `mcp#Ping` | Health check |
43 | `tools/list` | `tools/list` tool | `mcp#ToolsList` | Discover available tools |
44 | `tools/call` | `tools/call` tool | `mcp#ToolsCall` | Execute tools/services |
45 | `resources/list` | `resources/list` tool | `mcp#ResourcesList` | Discover entities |
46 | `resources/read` | `resources/read` tool | `mcp#ResourcesRead` | Read entity data |
47
48 ## Benefits
49
50 ### 1. Separation of Concerns
51
52 - **SDK Servlet**: Protocol handling, transport, validation
53 - **McpServices.xml**: Business logic, security, data access
54 - **Clean interfaces**: Well-defined service contracts
55
56 ### 2. Code Reuse
57
58 - **Single source of truth**: All MCP logic in McpServices.xml
59 - **Multiple transports**: Same services work with different servlets
60 - **Testable services**: Can test business logic independently
61
62 ### 3. Standards Compliance
63
64 - **MCP SDK**: Guaranteed protocol compliance
65 - **JSON Schema**: Automatic validation
66 - **Error handling**: Standardized responses
67
68 ### 4. Moqui Integration
69
70 - **Security**: Leverages Moqui authentication/authorization
71 - **Auditing**: Built-in artifact hit tracking
72 - **Transactions**: Proper transaction management
73 - **Error handling**: Moqui exception handling
74
75 ## Configuration
76
77 ### Dependencies
78
79 ```gradle
80 dependencies {
81 // MCP Java SDK for transport and protocol
82 implementation 'io.modelcontextprotocol.sdk:mcp-sdk:1.0.0'
83
84 // Jackson for JSON handling (required by MCP SDK)
85 implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
86 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2'
87
88 // Servlet API
89 compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
90 }
91 ```
92
93 ### Web.xml Configuration
94
95 ```xml
96 <servlet>
97 <servlet-name>McpSdkServicesServlet</servlet-name>
98 <servlet-class>org.moqui.mcp.McpSdkServicesServlet</servlet-class>
99
100 <init-param>
101 <param-name>moqui-name</param-name>
102 <param-value>moqui-mcp-2</param-value>
103 </init-param>
104
105 <async-supported>true</async-supported>
106 <load-on-startup>1</load-on-startup>
107 </servlet>
108
109 <servlet-mapping>
110 <servlet-name>McpSdkServicesServlet</servlet-name>
111 <url-pattern>/mcp/*</url-pattern>
112 </servlet-mapping>
113 ```
114
115 ## Available Tools
116
117 ### Core MCP Tools
118
119 | Tool | Service | Description |
120 |------|---------|-------------|
121 | `initialize` | `mcp#Initialize` | Initialize MCP session with capabilities negotiation |
122 | `ping` | `mcp#Ping` | Health check and server status |
123 | `tools/list` | `mcp#ToolsList` | List all available Moqui services as tools |
124 | `tools/call` | `mcp#ToolsCall` | Execute any Moqui service |
125 | `resources/list` | `mcp#ResourcesList` | List all accessible entities as resources |
126 | `resources/read` | `mcp#ResourcesRead` | Read entity data and metadata |
127 | `debug/component` | `debug#ComponentStatus` | Debug component status and configuration |
128
129 ### Tool Examples
130
131 #### Initialize Session
132
133 ```bash
134 curl -X POST http://localhost:8080/mcp/message \
135 -H "Content-Type: application/json" \
136 -d '{
137 "jsonrpc": "2.0",
138 "id": "init-1",
139 "method": "tools/call",
140 "params": {
141 "name": "initialize",
142 "arguments": {
143 "protocolVersion": "2025-06-18",
144 "clientInfo": {
145 "name": "Test Client",
146 "version": "1.0.0"
147 }
148 }
149 }
150 }'
151 ```
152
153 #### List Available Tools
154
155 ```bash
156 curl -X POST http://localhost:8080/mcp/message \
157 -H "Content-Type: application/json" \
158 -d '{
159 "jsonrpc": "2.0",
160 "id": "tools-1",
161 "method": "tools/call",
162 "params": {
163 "name": "tools/list",
164 "arguments": {}
165 }
166 }'
167 ```
168
169 #### Execute Moqui Service
170
171 ```bash
172 curl -X POST http://localhost:8080/mcp/message \
173 -H "Content-Type: application/json" \
174 -d '{
175 "jsonrpc": "2.0",
176 "id": "call-1",
177 "method": "tools/call",
178 "params": {
179 "name": "tools/call",
180 "arguments": {
181 "name": "org.moqui.entity.EntityServices.find#List",
182 "arguments": {
183 "entityName": "moqui.security.UserAccount",
184 "fields": ["username", "emailAddress"],
185 "limit": 10
186 }
187 }
188 }
189 }'
190 ```
191
192 ## Available Resources
193
194 ### Entity Resources
195
196 - **`entity://`** - List all entities
197 - **`entity://EntityName`** - Read specific entity data
198
199 #### Resource Examples
200
201 ```bash
202 # List all entities
203 curl -X POST http://localhost:8080/mcp/message \
204 -H "Content-Type: application/json" \
205 -d '{
206 "jsonrpc": "2.0",
207 "id": "res-1",
208 "method": "tools/call",
209 "params": {
210 "name": "resources/list",
211 "arguments": {}
212 }
213 }'
214
215 # Read specific entity
216 curl -X POST http://localhost:8080/mcp/message \
217 -H "Content-Type: application/json" \
218 -d '{
219 "jsonrpc": "2.0",
220 "id": "res-2",
221 "method": "tools/call",
222 "params": {
223 "name": "resources/read",
224 "arguments": {
225 "uri": "entity://moqui.security.UserAccount"
226 }
227 }
228 }'
229 ```
230
231 ## Available Prompts
232
233 ### Entity Query Prompt
234
235 Generate Moqui entity queries:
236
237 ```bash
238 curl -X POST http://localhost:8080/mcp/message \
239 -H "Content-Type: application/json" \
240 -d '{
241 "jsonrpc": "2.0",
242 "id": "prompt-1",
243 "method": "prompts/get",
244 "params": {
245 "name": "entity-query",
246 "arguments": {
247 "entity": "mantle.order.OrderHeader",
248 "purpose": "Find recent orders",
249 "fields": "orderId, orderDate, grandTotal"
250 }
251 }
252 }'
253 ```
254
255 ### Service Execution Prompt
256
257 Generate Moqui service execution plans:
258
259 ```bash
260 curl -X POST http://localhost:8080/mcp/message \
261 -H "Content-Type: application/json" \
262 -d '{
263 "jsonrpc": "2.0",
264 "id": "prompt-2",
265 "method": "prompts/get",
266 "params": {
267 "name": "service-execution",
268 "arguments": {
269 "serviceName": "create#OrderItem",
270 "description": "Create a new order item",
271 "parameters": "{\"orderId\":\"string\",\"productId\":\"string\",\"quantity\":\"number\"}"
272 }
273 }
274 }'
275 ```
276
277 ## Service Implementation Details
278
279 ### McpServices.xml Features
280
281 The `McpServices.xml` provides comprehensive MCP functionality:
282
283 #### 1. Service Discovery (`mcp#ToolsList`)
284
285 ```xml
286 <service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true">
287 <description>Handle MCP tools/list request with direct Moqui service discovery</description>
288 <actions>
289 <script><![CDATA[
290 // Get all service names from Moqui service engine
291 def allServiceNames = ec.service.getKnownServiceNames()
292 def availableTools = []
293
294 // Convert services to MCP tools
295 for (serviceName in allServiceNames) {
296 if (ec.service.hasPermission(serviceName)) {
297 def serviceInfo = ec.service.getServiceInfo(serviceName)
298 // Convert to MCP tool format...
299 }
300 }
301 ]]></script>
302 </actions>
303 </service>
304 ```
305
306 #### 2. Service Execution (`mcp#ToolsCall`)
307
308 ```xml
309 <service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true">
310 <description>Handle MCP tools/call request with direct Moqui service execution</description>
311 <actions>
312 <script><![CDATA[
313 // Validate service exists and user has permission
314 if (!ec.service.hasPermission(name)) {
315 throw new Exception("Permission denied for tool: ${name}")
316 }
317
318 // Create audit record
319 def artifactHit = ec.entity.makeValue("moqui.server.ArtifactHit")
320 // ... audit setup ...
321
322 // Execute service
323 def serviceResult = ec.service.sync().name(name).parameters(arguments).call()
324
325 // Convert result to MCP format
326 result = [content: content, isError: false]
327 ]]></script>
328 </actions>
329 </service>
330 ```
331
332 #### 3. Entity Access (`mcp#ResourcesRead`)
333
334 ```xml
335 <service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true">
336 <description>Handle MCP resources/read request with Moqui entity queries</description>
337 <actions>
338 <script><![CDATA[
339 // Parse entity URI and validate permissions
340 if (!ec.user.hasPermission("entity:${entityName}", "VIEW")) {
341 throw new Exception("Permission denied for entity: ${entityName}")
342 }
343
344 // Get entity definition and query data
345 def entityDef = ec.entity.getEntityDefinition(entityName)
346 def entityList = ec.entity.find(entityName).limit(100).list()
347
348 // Build comprehensive response
349 result = [contents: [[
350 uri: uri,
351 mimeType: "application/json",
352 text: entityInfo + data
353 ]]]
354 ]]></script>
355 </actions>
356 </service>
357 ```
358
359 ## Security Model
360
361 ### Authentication Integration
362
363 - **Moqui Authentication**: Uses existing Moqui user accounts
364 - **Session Management**: Leverages Moqui web sessions
365 - **Permission Checking**: Validates service and entity permissions
366 - **Audit Trail**: Records all MCP operations in ArtifactHit
367
368 ### Permission Model
369
370 | Operation | Permission Check | Description |
371 |------------|------------------|-------------|
372 | Service Execution | `ec.service.hasPermission(serviceName)` | Check service-level permissions |
373 | Entity Access | `ec.user.hasPermission("entity:EntityName", "VIEW")` | Check entity-level permissions |
374 | Resource Access | `ec.user.hasPermission("entity:EntityName", "VIEW")` | Check entity read permissions |
375
376 ### Audit Logging
377
378 All MCP operations are automatically logged:
379
380 ```groovy
381 def artifactHit = ec.entity.makeValue("moqui.server.ArtifactHit")
382 artifactHit.artifactType = "MCP"
383 artifactHit.artifactSubType = "Tool" // or "Resource"
384 artifactHit.artifactName = serviceName
385 artifactHit.parameterString = new JsonBuilder(arguments).toString()
386 artifactHit.userId = ec.user.userId
387 artifactHit.create()
388 ```
389
390 ## Error Handling
391
392 ### Service-Level Errors
393
394 The McpServices.xml provides comprehensive error handling:
395
396 ```groovy
397 try {
398 def serviceResult = ec.service.sync().name(name).parameters(arguments).call()
399 result = [content: content, isError: false]
400 } catch (Exception e) {
401 // Update audit record with error
402 artifactHit.wasError = "Y"
403 artifactHit.errorMessage = e.message
404 artifactHit.update()
405
406 result = [
407 content: [[type: "text", text: "Error executing tool ${name}: ${e.message}"]],
408 isError: true
409 ]
410 }
411 ```
412
413 ### SDK-Level Errors
414
415 The MCP SDK handles protocol-level errors:
416
417 - **JSON-RPC validation** - Invalid requests
418 - **Schema validation** - Parameter validation
419 - **Transport errors** - Connection issues
420 - **Timeout handling** - Long-running operations
421
422 ## Performance Considerations
423
424 ### Service Caching
425
426 - **Service definitions** cached by Moqui service engine
427 - **Entity metadata** cached by entity engine
428 - **Permission checks** cached for performance
429 - **Audit records** batched for efficiency
430
431 ### Connection Management
432
433 - **SSE connections** managed by MCP SDK
434 - **Async processing** for non-blocking operations
435 - **Connection limits** prevent resource exhaustion
436 - **Graceful shutdown** on servlet destroy
437
438 ### Query Optimization
439
440 - **Entity queries** limited to 100 records by default
441 - **Field selection** reduces data transfer
442 - **Pagination support** for large result sets
443 - **Index utilization** through proper query construction
444
445 ## Monitoring and Debugging
446
447 ### Component Status Tool
448
449 Use the debug tool to check component status:
450
451 ```bash
452 curl -X POST http://localhost:8080/mcp/message \
453 -H "Content-Type: application/json" \
454 -d '{
455 "jsonrpc": "2.0",
456 "id": "debug-1",
457 "method": "tools/call",
458 "params": {
459 "name": "debug/component",
460 "arguments": {}
461 }
462 }'
463 ```
464
465 ### Log Categories
466
467 - `org.moqui.mcp.McpSdkServicesServlet` - Servlet operations
468 - `org.moqui.mcp.McpServices` - Service operations
469 - `io.modelcontextprotocol.sdk` - MCP SDK operations
470
471 ### Audit Reports
472
473 Query audit records:
474
475 ```sql
476 SELECT artifactName, COUNT(*) as callCount,
477 AVG(runningTimeMillis) as avgTime,
478 COUNT(CASE WHEN wasError = 'Y' THEN 1 END) as errorCount
479 FROM moqui.server.ArtifactHit
480 WHERE artifactType = 'MCP'
481 GROUP BY artifactName
482 ORDER BY callCount DESC;
483 ```
484
485 ## Comparison: Approaches
486
487 | Feature | Custom Servlet | SDK + Custom Logic | SDK + Services |
488 |---------|----------------|-------------------|-----------------|
489 | **Protocol Compliance** | Manual | SDK | SDK |
490 | **Business Logic** | Custom | Custom | Services |
491 | **Code Reuse** | Low | Medium | High |
492 | **Testing** | Complex | Medium | Simple |
493 | **Maintenance** | High | Medium | Low |
494 | **Security** | Custom | Custom | Moqui |
495 | **Auditing** | Custom | Custom | Built-in |
496 | **Performance** | Unknown | Good | Optimized |
497
498 ## Migration Guide
499
500 ### From Custom Implementation
501
502 1. **Add MCP SDK dependency** to build.gradle
503 2. **Deploy McpServices.xml** (if not already present)
504 3. **Replace servlet** with `McpSdkServicesServlet`
505 4. **Update web.xml** configuration
506 5. **Test existing clients** for compatibility
507 6. **Update documentation** with new tool names
508
509 ### Client Compatibility
510
511 The SDK + Services approach maintains compatibility:
512
513 - **Same JSON-RPC 2.0 protocol**
514 - **Enhanced tool interfaces** (more services available)
515 - **Better error responses** (standardized)
516 - **Improved security** (Moqui integration)
517
518 ## Best Practices
519
520 ### 1. Service Design
521
522 - **Keep services focused** on single responsibilities
523 - **Use proper error handling** with meaningful messages
524 - **Document parameters** with descriptions and types
525 - **Validate inputs** before processing
526
527 ### 2. Security
528
529 - **Always check permissions** before operations
530 - **Use parameterized queries** to prevent injection
531 - **Log all access** for audit trails
532 - **Sanitize outputs** to prevent data leakage
533
534 ### 3. Performance
535
536 - **Limit result sets** to prevent memory issues
537 - **Use appropriate indexes** for entity queries
538 - **Cache frequently accessed data**
539 - **Monitor execution times** for optimization
540
541 ### 4. Error Handling
542
543 - **Provide meaningful error messages**
544 - **Include context** for debugging
545 - **Log errors appropriately**
546 - **Graceful degradation** for failures
547
548 ## Future Enhancements
549
550 ### Planned Features
551
552 1. **Async Services** - Support for long-running operations
553 2. **Streaming Resources** - Large dataset streaming
554 3. **Tool Categories** - Organize services by domain
555 4. **Dynamic Registration** - Runtime service addition
556 5. **Enhanced Prompts** - More sophisticated prompt templates
557
558 ### Advanced Integration
559
560 1. **Event-Driven Updates** - Real-time entity change notifications
561 2. **Workflow Integration** - MCP-triggered Moqui workflows
562 3. **Multi-tenancy** - Tenant-aware MCP operations
563 4. **GraphQL Support** - GraphQL as alternative to entity access
564
565 ## References
566
567 - [MCP Java SDK Documentation](https://modelcontextprotocol.io/sdk/java/mcp-server)
568 - [MCP Protocol Specification](https://modelcontextprotocol.io/specification/)
569 - [Moqui Framework Documentation](https://moqui.org/docs/)
570 - [Moqui Service Engine](https://moqui.org/docs/docs/moqui-service-engine)
571 - [Moqui Entity Engine](https://moqui.org/docs/docs/moqui-entity-engine)
...\ No newline at end of file ...\ No newline at end of file
1 # Moqui MCP SDK Servlet
2
3 This document describes the MCP servlet implementation using the official Java MCP SDK library (`io.modelcontextprotocol.sdk`).
4
5 ## Overview
6
7 The `McpSdkSseServlet` uses the official MCP Java SDK to provide standards-compliant MCP server functionality with:
8
9 - **Full MCP protocol compliance** using official SDK
10 - **SSE transport** with proper session management
11 - **Built-in tool/resource/prompt registration**
12 - **JSON schema validation** for all inputs
13 - **Standard error handling** and response formatting
14 - **Async support** for scalable connections
15
16 ## Architecture
17
18 ### MCP SDK Integration
19
20 The servlet uses these key MCP SDK components:
21
22 - `HttpServletSseServerTransportProvider` - SSE transport implementation
23 - `McpSyncServer` - Synchronous MCP server API
24 - `McpServerFeatures.SyncToolSpecification` - Tool definitions
25 - `McpServerFeatures.SyncResourceSpecification` - Resource definitions
26 - `McpServerFeatures.SyncPromptSpecification` - Prompt definitions
27
28 ### Endpoint Structure
29
30 ```
31 /mcp/sse - SSE endpoint for server-to-client events (handled by SDK)
32 /mcp/message - Message endpoint for client-to-server requests (handled by SDK)
33 ```
34
35 ## Dependencies
36
37 ### Build Configuration
38
39 Add to `build.gradle`:
40
41 ```gradle
42 dependencies {
43 // MCP Java SDK
44 implementation 'io.modelcontextprotocol.sdk:mcp-sdk:1.0.0'
45
46 // Jackson for JSON handling (required by MCP SDK)
47 implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
48 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2'
49
50 // Servlet API
51 compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
52 }
53 ```
54
55 ### Maven Dependencies
56
57 ```xml
58 <dependencies>
59 <dependency>
60 <groupId>io.modelcontextprotocol.sdk</groupId>
61 <artifactId>mcp-sdk</artifactId>
62 <version>1.0.0</version>
63 </dependency>
64
65 <dependency>
66 <groupId>com.fasterxml.jackson.core</groupId>
67 <artifactId>jackson-databind</artifactId>
68 <version>2.15.2</version>
69 </dependency>
70
71 <dependency>
72 <groupId>com.fasterxml.jackson.datatype</groupId>
73 <artifactId>jackson-datatype-jsr310</artifactId>
74 <version>2.15.2</version>
75 </dependency>
76 </dependencies>
77 ```
78
79 ## Configuration
80
81 ### Web.xml Configuration
82
83 ```xml
84 <servlet>
85 <servlet-name>McpSdkSseServlet</servlet-name>
86 <servlet-class>org.moqui.mcp.McpSdkSseServlet</servlet-class>
87
88 <init-param>
89 <param-name>moqui-name</param-name>
90 <param-value>moqui-mcp-2</param-value>
91 </init-param>
92
93 <async-supported>true</async-supported>
94 <load-on-startup>1</load-on-startup>
95 </servlet>
96
97 <servlet-mapping>
98 <servlet-name>McpSdkSseServlet</servlet-name>
99 <url-pattern>/mcp/*</url-pattern>
100 </servlet-mapping>
101 ```
102
103 ## Available Tools
104
105 ### Entity Tools
106
107 | Tool | Description | Parameters |
108 |------|-------------|------------|
109 | `EntityFind` | Query entities | `entity` (required), `fields`, `constraint`, `limit`, `offset` |
110 | `EntityCreate` | Create entity records | `entity` (required), `fields` (required) |
111 | `EntityUpdate` | Update entity records | `entity` (required), `fields` (required), `constraint` |
112 | `EntityDelete` | Delete entity records | `entity` (required), `constraint` |
113
114 ### Service Tools
115
116 | Tool | Description | Parameters |
117 |------|-------------|------------|
118 | `ServiceCall` | Execute Moqui services | `service` (required), `parameters` |
119 | `SystemStatus` | Get system statistics | `includeMetrics`, `includeCache` |
120
121 ## Available Resources
122
123 ### Entity Resources
124
125 - `entity://` - List all entities
126 - `entity://EntityName` - Get specific entity definition
127
128 **Example:**
129 ```bash
130 # List all entities
131 curl -H "Accept: application/json" \
132 http://localhost:8080/mcp/resources?uri=entity://
133
134 # Get specific entity
135 curl -H "Accept: application/json" \
136 http://localhost:8080/mcp/resources?uri=entity://moqui.security.UserAccount
137 ```
138
139 ## Available Prompts
140
141 ### Entity Query Prompt
142
143 Generate Moqui entity queries:
144
145 ```json
146 {
147 "name": "entity-query",
148 "arguments": {
149 "entity": "mantle.order.OrderHeader",
150 "purpose": "Find recent orders",
151 "fields": "orderId, orderDate, grandTotal"
152 }
153 }
154 ```
155
156 ### Service Definition Prompt
157
158 Generate Moqui service definitions:
159
160 ```json
161 {
162 "name": "service-definition",
163 "arguments": {
164 "serviceName": "create#OrderItem",
165 "description": "Create a new order item",
166 "parameters": "{\"orderId\":\"string\",\"productId\":\"string\",\"quantity\":\"number\"}"
167 }
168 }
169 ```
170
171 ## Usage Examples
172
173 ### Client Connection
174
175 ```javascript
176 // Using MCP SDK client
177 import { McpClient } from '@modelcontextprotocol/sdk/client/index.js';
178 import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
179
180 const transport = new StdioClientTransport({
181 command: 'curl',
182 args: ['-X', 'POST', 'http://localhost:8080/mcp/message']
183 });
184
185 const client = new McpClient(
186 transport,
187 {
188 name: "Moqui Client",
189 version: "1.0.0"
190 }
191 );
192
193 await client.connect();
194
195 // List tools
196 const tools = await client.listTools();
197 console.log('Available tools:', tools.tools);
198
199 // Call a tool
200 const result = await client.callTool({
201 name: "EntityFind",
202 arguments: {
203 entity: "moqui.security.UserAccount",
204 limit: 10
205 }
206 });
207 console.log('Tool result:', result);
208 ```
209
210 ### Raw HTTP Client
211
212 ```bash
213 # Initialize connection
214 curl -X POST http://localhost:8080/mcp/message \
215 -H "Content-Type: application/json" \
216 -d '{
217 "jsonrpc": "2.0",
218 "id": "init-1",
219 "method": "initialize",
220 "params": {
221 "clientInfo": {
222 "name": "Test Client",
223 "version": "1.0.0"
224 }
225 }
226 }'
227
228 # List tools
229 curl -X POST http://localhost:8080/mcp/message \
230 -H "Content-Type: application/json" \
231 -d '{
232 "jsonrpc": "2.0",
233 "id": "tools-1",
234 "method": "tools/list",
235 "params": {}
236 }'
237
238 # Call EntityFind tool
239 curl -X POST http://localhost:8080/mcp/message \
240 -H "Content-Type: application/json" \
241 -d '{
242 "jsonrpc": "2.0",
243 "id": "call-1",
244 "method": "tools/call",
245 "params": {
246 "name": "EntityFind",
247 "arguments": {
248 "entity": "moqui.security.UserAccount",
249 "fields": ["username", "emailAddress"],
250 "limit": 5
251 }
252 }
253 }'
254 ```
255
256 ## Tool Implementation Details
257
258 ### EntityFind Tool
259
260 ```groovy
261 def entityFindTool = new McpServerFeatures.SyncToolSpecification(
262 new Tool("EntityFind", "Find entities in Moqui using entity queries", """
263 {
264 "type": "object",
265 "properties": {
266 "entity": {"type": "string", "description": "Entity name"},
267 "fields": {"type": "array", "items": {"type": "string"}, "description": "Fields to select"},
268 "constraint": {"type": "string", "description": "Constraint expression"},
269 "limit": {"type": "number", "description": "Maximum results"},
270 "offset": {"type": "number", "description": "Results offset"}
271 },
272 "required": ["entity"]
273 }
274 """),
275 { exchange, arguments ->
276 return executeWithEc { ec ->
277 String entityName = arguments.get("entity") as String
278 List<String> fields = arguments.get("fields") as List<String>
279 String constraint = arguments.get("constraint") as String
280 Integer limit = arguments.get("limit") as Integer
281 Integer offset = arguments.get("offset") as Integer
282
283 def finder = ec.entity.find(entityName).selectFields(fields ?: ["*"])
284 if (constraint) {
285 finder.condition(constraint)
286 }
287 if (offset) {
288 finder.offset(offset)
289 }
290 if (limit) {
291 finder.limit(limit)
292 }
293 def result = finder.list()
294
295 new CallToolResult([
296 new TextContent("Found ${result.size()} records in ${entityName}: ${result}")
297 ], false)
298 }
299 }
300 )
301 ```
302
303 ## Error Handling
304
305 The MCP SDK provides standardized error handling:
306
307 ### JSON-RPC Errors
308
309 | Code | Description | Example |
310 |------|-------------|---------|
311 | `-32600` | Invalid Request | Malformed JSON-RPC |
312 | `-32601` | Method Not Found | Unknown method name |
313 | `-32602` | Invalid Params | Missing required parameters |
314 | `-32603` | Internal Error | Server-side exception |
315
316 ### Tool Execution Errors
317
318 ```groovy
319 return new CallToolResult([
320 new TextContent("Error: " + e.message)
321 ], true) // isError = true
322 ```
323
324 ### Resource Errors
325
326 ```groovy
327 throw new IllegalArgumentException("Entity not found: ${entityName}")
328 ```
329
330 ## Security
331
332 ### Authentication Integration
333
334 The servlet integrates with Moqui's security framework:
335
336 ```groovy
337 // Authenticate as admin for MCP operations
338 if (!ec.user?.userId) {
339 try {
340 ec.user.loginUser("admin", "admin")
341 } catch (Exception e) {
342 logger.warn("MCP Admin login failed: ${e.message}")
343 }
344 }
345 ```
346
347 ### CORS Support
348
349 ```groovy
350 private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName) {
351 String originHeader = request.getHeader("Origin")
352 if (originHeader) {
353 response.setHeader("Access-Control-Allow-Origin", originHeader)
354 response.setHeader("Access-Control-Allow-Credentials", "true")
355 }
356
357 String methodHeader = request.getHeader("Access-Control-Request-Method")
358 if (methodHeader) {
359 response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
360 response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
361 response.setHeader("Access-Control-Max-Age", "3600")
362 return true
363 }
364 return false
365 }
366 ```
367
368 ## Monitoring and Logging
369
370 ### Log Categories
371
372 - `org.moqui.mcp.McpSdkSseServlet` - Servlet operations
373 - `io.modelcontextprotocol.sdk` - MCP SDK operations
374
375 ### Key Log Messages
376
377 ```
378 INFO - McpSdkSseServlet initialized for webapp moqui-mcp-2
379 INFO - MCP Server started with SSE transport
380 INFO - Registered 6 Moqui tools
381 INFO - Registered Moqui resources
382 INFO - Registered Moqui prompts
383 ```
384
385 ## Performance Considerations
386
387 ### Connection Management
388
389 - **Async processing** for non-blocking operations
390 - **Connection pooling** handled by servlet container
391 - **Session management** provided by MCP SDK
392 - **Graceful shutdown** on servlet destroy
393
394 ### Memory Usage
395
396 - **Tool definitions** loaded once at startup
397 - **ExecutionContext** created per request
398 - **JSON parsing** handled by Jackson
399 - **SSE connections** managed efficiently by SDK
400
401 ## Comparison: Custom vs SDK Implementation
402
403 | Feature | Custom Implementation | SDK Implementation |
404 |---------|---------------------|-------------------|
405 | **Protocol Compliance** | Manual implementation | Guaranteed by SDK |
406 | **Error Handling** | Custom code | Standardized |
407 | **JSON Schema** | Manual validation | Automatic |
408 | **SSE Transport** | Custom implementation | Built-in |
409 | **Session Management** | Manual code | Built-in |
410 | **Testing** | Custom tests | SDK tested |
411 | **Maintenance** | High effort | Low effort |
412 | **Standards Updates** | Manual updates | Automatic with SDK |
413
414 ## Migration from Custom Servlet
415
416 ### Benefits of SDK Migration
417
418 1. **Standards Compliance** - Guaranteed MCP protocol compliance
419 2. **Reduced Maintenance** - Less custom code to maintain
420 3. **Better Error Handling** - Standardized error responses
421 4. **Future Compatibility** - Automatic updates with SDK releases
422 5. **Testing** - SDK includes comprehensive test suite
423
424 ### Migration Steps
425
426 1. **Add MCP SDK dependency** to build.gradle
427 2. **Replace servlet class** with `McpSdkSseServlet`
428 3. **Update web.xml** configuration
429 4. **Test existing clients** for compatibility
430 5. **Update documentation** with new endpoints
431
432 ### Client Compatibility
433
434 The SDK implementation maintains compatibility with existing MCP clients:
435
436 - Same JSON-RPC 2.0 protocol
437 - Same tool/resource/prompt interfaces
438 - Same authentication patterns
439 - Better error responses and validation
440
441 ## Troubleshooting
442
443 ### Common Issues
444
445 1. **Dependency Conflicts**
446 ```bash
447 # Check for Jackson version conflicts
448 ./gradlew dependencies | grep jackson
449 ```
450
451 2. **Async Support Issues**
452 ```xml
453 <!-- Ensure async is enabled in web.xml -->
454 <async-supported>true</async-supported>
455 ```
456
457 3. **Servlet Container Compatibility**
458 - Requires Servlet 3.0+ for async support
459 - Test with Tomcat 8.5+, Jetty 9.4+, or similar
460
461 4. **MCP SDK Version**
462 ```gradle
463 // Use latest stable version
464 implementation 'io.modelcontextprotocol.sdk:mcp-sdk:1.0.0'
465 ```
466
467 ### Debug Mode
468
469 Enable debug logging:
470
471 ```xml
472 <Logger name="org.moqui.mcp.McpSdkSseServlet" level="DEBUG"/>
473 <Logger name="io.modelcontextprotocol.sdk" level="DEBUG"/>
474 ```
475
476 ## Future Enhancements
477
478 ### Planned Features
479
480 1. **Async Tools** - Use `McpServerFeatures.AsyncToolSpecification`
481 2. **Streaming Resources** - Large data set streaming
482 3. **Tool Categories** - Organize tools by domain
483 4. **Dynamic Registration** - Runtime tool/resource addition
484 5. **Metrics Collection** - Performance and usage metrics
485
486 ### Advanced Integration
487
488 1. **Spring Integration** - Use MCP SDK Spring starters
489 2. **WebFlux Support** - Reactive transport providers
490 3. **WebSocket Transport** - Alternative to SSE
491 4. **Load Balancing** - Multi-instance deployment
492
493 ## References
494
495 - [MCP Java SDK Documentation](https://modelcontextprotocol.io/sdk/java/mcp-server)
496 - [MCP Protocol Specification](https://modelcontextprotocol.io/specification/)
497 - [Moqui Framework Documentation](https://moqui.org/docs/)
498 - [Servlet 3.0 Specification](https://jakarta.ee/specifications/servlet/3.0/)
...\ No newline at end of file ...\ No newline at end of file
1 # Moqui MCP SSE Servlet
2
3 This document describes the new MCP SSE (Server-Sent Events) servlet implementation based on the official Java MCP SDK SSE Servlet approach.
4
5 ## Overview
6
7 The `McpSseServlet` implements the MCP SSE transport specification using the traditional Servlet API, providing:
8
9 - **Asynchronous message handling** using Servlet async support
10 - **Session management** for multiple client connections
11 - **Two types of endpoints**:
12 - SSE endpoint (`/sse`) for server-to-client events
13 - Message endpoint (`/mcp/message`) for client-to-server requests
14 - **Error handling** and response formatting
15 - **Graceful shutdown** support
16 - **Keep-alive pings** to maintain connections
17 - **Connection limits** to prevent resource exhaustion
18
19 ## Architecture
20
21 ### Endpoint Structure
22
23 ```
24 /sse - SSE endpoint for server-to-client events
25 /mcp/message - Message endpoint for client-to-server requests
26 ```
27
28 ### Connection Flow
29
30 1. **Client connects** to `/sse` endpoint
31 2. **Server establishes** SSE connection with unique session ID
32 3. **Client sends** JSON-RPC messages to `/mcp/message`
33 4. **Server processes** messages and sends responses via SSE
34 5. **Keep-alive pings** maintain connection health
35
36 ## Configuration
37
38 ### Servlet Configuration Parameters
39
40 | Parameter | Default | Description |
41 |-----------|---------|-------------|
42 | `moqui-name` | - | Moqui webapp name (required) |
43 | `sseEndpoint` | `/sse` | SSE endpoint path |
44 | `messageEndpoint` | `/mcp/message` | Message endpoint path |
45 | `keepAliveIntervalSeconds` | `30` | Keep-alive ping interval |
46 | `maxConnections` | `100` | Maximum concurrent SSE connections |
47
48 ### Web.xml Configuration
49
50 ```xml
51 <servlet>
52 <servlet-name>McpSseServlet</servlet-name>
53 <servlet-class>org.moqui.mcp.McpSseServlet</servlet-class>
54
55 <init-param>
56 <param-name>moqui-name</param-name>
57 <param-value>moqui-mcp-2</param-value>
58 </init-param>
59
60 <init-param>
61 <param-name>keepAliveIntervalSeconds</param-name>
62 <param-value>30</param-value>
63 </init-param>
64
65 <async-supported>true</async-supported>
66 </servlet>
67
68 <servlet-mapping>
69 <servlet-name>McpSseServlet</servlet-name>
70 <url-pattern>/sse/*</url-pattern>
71 </servlet-mapping>
72
73 <servlet-mapping>
74 <servlet-name>McpSseServlet</servlet-name>
75 <url-pattern>/mcp/message/*</url-pattern>
76 </servlet-mapping>
77 ```
78
79 ## Usage Examples
80
81 ### Client Connection Flow
82
83 #### 1. Establish SSE Connection
84
85 ```bash
86 curl -N -H "Accept: text/event-stream" \
87 http://localhost:8080/sse
88 ```
89
90 **Response:**
91 ```
92 event: connect
93 data: {"type":"connected","sessionId":"uuid-123","timestamp":1234567890,"serverInfo":{"name":"Moqui MCP SSE Server","version":"2.0.0","protocolVersion":"2025-06-18"}}
94
95 event: ping
96 data: {"type":"ping","timestamp":1234567890,"connections":1}
97 ```
98
99 #### 2. Initialize MCP Session
100
101 ```bash
102 curl -X POST http://localhost:8080/mcp/message \
103 -H "Content-Type: application/json" \
104 -d '{
105 "jsonrpc": "2.0",
106 "id": "init-1",
107 "method": "initialize",
108 "params": {
109 "clientInfo": {
110 "name": "Test Client",
111 "version": "1.0.0"
112 }
113 }
114 }'
115 ```
116
117 **Response:**
118 ```json
119 {
120 "jsonrpc": "2.0",
121 "id": "init-1",
122 "result": {
123 "protocolVersion": "2025-06-18",
124 "capabilities": {
125 "tools": {"listChanged": true},
126 "resources": {"subscribe": true, "listChanged": true},
127 "logging": {},
128 "notifications": {}
129 },
130 "serverInfo": {
131 "name": "Moqui MCP SSE Server",
132 "version": "2.0.0"
133 },
134 "sessionId": "uuid-123"
135 }
136 }
137 ```
138
139 #### 3. List Available Tools
140
141 ```bash
142 curl -X POST http://localhost:8080/mcp/message \
143 -H "Content-Type: application/json" \
144 -d '{
145 "jsonrpc": "2.0",
146 "id": "tools-1",
147 "method": "tools/list",
148 "params": {}
149 }'
150 ```
151
152 #### 4. Call a Tool
153
154 ```bash
155 curl -X POST http://localhost:8080/mcp/message \
156 -H "Content-Type: application/json" \
157 -d '{
158 "jsonrpc": "2.0",
159 "id": "call-1",
160 "method": "tools/call",
161 "params": {
162 "name": "EntityFind",
163 "arguments": {
164 "entity": "moqui.security.UserAccount",
165 "fields": ["username", "emailAddress"],
166 "limit": 10
167 }
168 }
169 }'
170 ```
171
172 ### JavaScript Client Example
173
174 ```javascript
175 class McpSseClient {
176 constructor(baseUrl) {
177 this.baseUrl = baseUrl;
178 this.sessionId = null;
179 this.eventSource = null;
180 this.messageId = 0;
181 }
182
183 async connect() {
184 // Establish SSE connection
185 this.eventSource = new EventSource(`${this.baseUrl}/sse`);
186
187 this.eventSource.addEventListener('connect', (event) => {
188 const data = JSON.parse(event.data);
189 console.log('Connected:', data);
190 });
191
192 this.eventSource.addEventListener('ping', (event) => {
193 const data = JSON.parse(event.data);
194 console.log('Ping:', data);
195 });
196
197 this.eventSource.addEventListener('initialized', (event) => {
198 const data = JSON.parse(event.data);
199 console.log('Server initialized:', data);
200 });
201
202 // Initialize MCP session
203 const initResponse = await this.sendMessage('initialize', {
204 clientInfo: {
205 name: 'JavaScript Client',
206 version: '1.0.0'
207 }
208 });
209
210 this.sessionId = initResponse.sessionId;
211 return initResponse;
212 }
213
214 async sendMessage(method, params = {}) {
215 const id = `msg-${++this.messageId}`;
216 const payload = {
217 jsonrpc: "2.0",
218 id: id,
219 method: method,
220 params: params
221 };
222
223 const response = await fetch(`${this.baseUrl}/mcp/message`, {
224 method: 'POST',
225 headers: {
226 'Content-Type': 'application/json'
227 },
228 body: JSON.stringify(payload)
229 });
230
231 return await response.json();
232 }
233
234 async listTools() {
235 return await this.sendMessage('tools/list');
236 }
237
238 async callTool(name, arguments) {
239 return await this.sendMessage('tools/call', {
240 name: name,
241 arguments: arguments
242 });
243 }
244
245 disconnect() {
246 if (this.eventSource) {
247 this.eventSource.close();
248 }
249 }
250 }
251
252 // Usage
253 const client = new McpSseClient('http://localhost:8080');
254
255 client.connect().then(() => {
256 console.log('MCP client connected');
257
258 // List tools
259 return client.listTools();
260 }).then(tools => {
261 console.log('Available tools:', tools);
262
263 // Call a tool
264 return client.callTool('EntityFind', {
265 entity: 'moqui.security.UserAccount',
266 limit: 5
267 });
268 }).then(result => {
269 console.log('Tool result:', result);
270 }).catch(error => {
271 console.error('MCP client error:', error);
272 });
273 ```
274
275 ## Features
276
277 ### Session Management
278
279 - **Unique session IDs** generated for each connection
280 - **Connection tracking** with metadata (user agent, timestamps)
281 - **Automatic cleanup** on connection close/error
282 - **Connection limits** to prevent resource exhaustion
283
284 ### Event Types
285
286 | Event Type | Description | Data |
287 |------------|-------------|------|
288 | `connect` | Initial connection established | Session info, server details |
289 | `ping` | Keep-alive ping | Timestamp, connection count |
290 | `initialized` | Server initialization notification | Session info, client details |
291 | `subscribed` | Subscription confirmation | Session, event type |
292 | `tool_result` | Tool execution result | Tool name, result data |
293 | `resource_update` | Resource change notification | Resource URI, change data |
294
295 ### Error Handling
296
297 - **JSON-RPC 2.0 compliant** error responses
298 - **Connection error recovery** with automatic cleanup
299 - **Resource exhaustion protection** with connection limits
300 - **Graceful degradation** on service failures
301
302 ### Security
303
304 - **CORS support** for cross-origin requests
305 - **Moqui authentication integration** with admin fallback
306 - **Session isolation** between clients
307 - **Input validation** for all requests
308
309 ## Monitoring
310
311 ### Connection Status
312
313 Get current server status:
314
315 ```bash
316 curl http://localhost:8080/mcp/message
317 ```
318
319 **Response:**
320 ```json
321 {
322 "serverInfo": {
323 "name": "Moqui MCP SSE Server",
324 "version": "2.0.0",
325 "protocolVersion": "2025-06-18"
326 },
327 "connections": {
328 "active": 3,
329 "max": 100
330 },
331 "endpoints": {
332 "sse": "/sse",
333 "message": "/mcp/message"
334 }
335 }
336 ```
337
338 ### Logging
339
340 The servlet provides detailed logging for:
341
342 - Connection establishment/cleanup
343 - Message processing
344 - Error conditions
345 - Performance metrics
346
347 Log levels:
348 - `INFO`: Connection events, message processing
349 - `WARN`: Connection errors, authentication issues
350 - `ERROR`: System errors, service failures
351 - `DEBUG`: Detailed request/response data
352
353 ## Comparison with Existing Servlet
354
355 | Feature | MoquiMcpServlet | McpSseServlet |
356 |---------|------------------|---------------|
357 | **Transport** | HTTP POST/GET | SSE + HTTP |
358 | **Async Support** | Limited | Full async |
359 | **Session Management** | Basic | Advanced |
360 | **Real-time Events** | No | Yes |
361 | **Connection Limits** | No | Yes |
362 | **Keep-alive** | No | Yes |
363 | **Standards Compliance** | Custom | MCP SSE Spec |
364
365 ## Migration Guide
366
367 ### From MoquiMcpServlet to McpSseServlet
368
369 1. **Update web.xml** to use the new servlet
370 2. **Update client code** to handle SSE connections
371 3. **Modify endpoints** from `/mcp/rpc` to `/mcp/message`
372 4. **Add SSE handling** for real-time events
373 5. **Update authentication** if using custom auth
374
375 ### Client Migration
376
377 **Old approach:**
378 ```bash
379 curl -X POST http://localhost:8080/mcp/rpc \
380 -H "Content-Type: application/json" \
381 -d '{"jsonrpc":"2.0","method":"ping","id":1}'
382 ```
383
384 **New approach:**
385 ```bash
386 # 1. Establish SSE connection
387 curl -N -H "Accept: text/event-stream" http://localhost:8080/sse &
388
389 # 2. Send messages
390 curl -X POST http://localhost:8080/mcp/message \
391 -H "Content-Type: application/json" \
392 -d '{"jsonrpc":"2.0","method":"ping","id":1}'
393 ```
394
395 ## Troubleshooting
396
397 ### Common Issues
398
399 1. **SSE Connection Fails**
400 - Check async support is enabled in web.xml
401 - Verify servlet container supports Servlet 3.0+
402 - Check firewall/proxy settings
403
404 2. **Messages Not Received**
405 - Verify session ID is valid
406 - Check connection is still active
407 - Review server logs for errors
408
409 3. **High Memory Usage**
410 - Reduce `maxConnections` parameter
411 - Check for connection leaks
412 - Monitor session cleanup
413
414 4. **Authentication Issues**
415 - Verify Moqui security configuration
416 - Check admin user credentials
417 - Review webapp security settings
418
419 ### Debug Mode
420
421 Enable debug logging by adding to log4j2.xml:
422
423 ```xml
424 <Logger name="org.moqui.mcp.McpSseServlet" level="DEBUG" additivity="false">
425 <AppenderRef ref="Console"/>
426 </Logger>
427 ```
428
429 ## Performance Considerations
430
431 ### Connection Scaling
432
433 - **Default limit**: 100 concurrent connections
434 - **Memory usage**: ~1KB per connection
435 - **CPU overhead**: Minimal for idle connections
436 - **Network bandwidth**: Low (keep-alive pings only)
437
438 ### Optimization Tips
439
440 1. **Adjust keep-alive interval** based on network conditions
441 2. **Monitor connection counts** and adjust limits
442 3. **Use connection pooling** for high-frequency clients
443 4. **Implement backpressure** for high-volume scenarios
444
445 ## Future Enhancements
446
447 Planned improvements:
448
449 1. **WebSocket support** as alternative to SSE
450 2. **Message queuing** for offline clients
451 3. **Load balancing** support for multiple instances
452 4. **Advanced authentication** with token-based auth
453 5. **Metrics and monitoring** integration
454 6. **Message compression** for large payloads
455
456 ## References
457
458 - [MCP Specification](https://modelcontextprotocol.io/)
459 - [Java MCP SDK](https://modelcontextprotocol.io/sdk/java/mcp-server)
460 - [Servlet 3.0 Specification](https://jakarta.ee/specifications/servlet/3.0/)
461 - [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
...\ No newline at end of file ...\ No newline at end of file
1 /*
2 * This software is in the public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 *
5 * To the extent possible under law, the author(s) have dedicated all
6 * copyright and related and neighboring rights to this software to the
7 * public domain worldwide. This software is distributed without any
8 * warranty.
9 *
10 * You should have received a copy of the CC0 Public Domain Dedication
11 * along with this software (see the LICENSE.md file). If not, see
12 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13 */
14
15 apply plugin: 'groovy'
16
17 def componentNode = parseComponent(project)
18 version = componentNode.'@version'
19 def jarBaseName = componentNode.'@name'
20 def moquiDir = projectDir.parentFile.parentFile.parentFile
21 def frameworkDir = file(moquiDir.absolutePath + '/framework')
22
23 repositories {
24 flatDir name: 'localLib', dirs: frameworkDir.absolutePath + '/lib'
25 mavenCentral()
26 }
27
28 // Log4J has annotation processors, disable to avoid warning
29 tasks.withType(JavaCompile) { options.compilerArgs << "-proc:none" }
30 tasks.withType(GroovyCompile) { options.compilerArgs << "-proc:none" }
31
32 dependencies {
33 implementation project(':framework')
34
35 // Servlet API (provided by framework, but needed for compilation)
36 compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
37 }
38
39 // by default the Java plugin runs test on build, change to not do that (only run test if explicit task)
40 check.dependsOn.clear()
41
42 task cleanLib(type: Delete) { delete fileTree(dir: projectDir.absolutePath+'/lib', include: '*') }
43 clean.dependsOn cleanLib
44
45 jar {
46 destinationDirectory = file(projectDir.absolutePath + '/lib')
47 archiveBaseName = jarBaseName
48 }
49 task copyDependencies { doLast {
50 copy { from (configurations.runtimeClasspath - project(':framework').configurations.runtimeClasspath)
51 into file(projectDir.absolutePath + '/lib') }
52 } }
53 copyDependencies.dependsOn cleanLib
54 jar.dependsOn copyDependencies
...\ No newline at end of file ...\ No newline at end of file
...@@ -14,13 +14,49 @@ ...@@ -14,13 +14,49 @@
14 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/component-definition-3.xsd" 14 xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/component-definition-3.xsd"
15 name="moqui-mcp-2"> 15 name="moqui-mcp-2">
16 16
17 <!-- No dependencies - uses only core framework --> 17
18
19 <!-- MCP SDK dependencies -->
20 <dependency>
21 <groupId>io.modelcontextprotocol.sdk</groupId>
22 <artifactId>mcp</artifactId>
23 <version>0.16.0</version>
24 </dependency>
25 <dependency>
26 <groupId>io.modelcontextprotocol.sdk</groupId>
27 <artifactId>mcp-core</artifactId>
28 <version>0.16.0</version>
29 </dependency>
30 <dependency>
31 <groupId>com.fasterxml.jackson.core</groupId>
32 <artifactId>jackson-databind</artifactId>
33 <version>2.15.2</version>
34 </dependency>
35 <dependency>
36 <groupId>com.fasterxml.jackson.core</groupId>
37 <artifactId>jackson-core</artifactId>
38 <version>2.15.2</version>
39 </dependency>
40 <dependency>
41 <groupId>io.projectreactor</groupId>
42 <artifactId>reactor-core</artifactId>
43 <version>3.5.10</version>
44 </dependency>
18 45
19 <entity-factory load-path="entity/" /> 46 <entity-factory load-path="entity/" />
20 <service-factory load-path="service/" /> 47 <service-factory load-path="service/" />
21 <screen-factory load-path="screen/" /> 48 <!-- <screen-factory load-path="screen/" /> -->
22 49
23 <!-- Load seed data --> 50 <!-- Load seed data -->
24 <entity-factory load-data="data/McpSecuritySeedData.xml" /> 51 <entity-factory load-data="data/McpSecuritySeedData.xml" />
52
53 <!-- Register MCP filter
54 <webapp-list>
55 <webapp name="webroot">
56 <filter name="McpFilter" class="org.moqui.mcp.McpFilter">
57 <url-pattern>/mcpservlet/*</url-pattern>
58 </filter>
59 </webapp>
60 </webapp-list> -->
25 61
26 </component> 62 </component>
......
No preview for this file type
1 <?xml version="1.0" encoding="UTF-8"?>
2 <!-- This software is in the public domain under CC0 1.0 Universal plus a
3 Grant of Patent License.
4
5 To the extent possible under law, author(s) have dedicated all
6 copyright and related and neighboring rights to this software to the
7 public domain worldwide. This software is distributed without any warranty.
8
9 You should have received a copy of the CC0 Public Domain Dedication
10 along with this software (see the LICENSE.md file). If not, see
11 <https://creativecommons.org/publicdomain/zero/1.0/>. -->
12
13 <screen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd"
14 require-authentication="false" track-artifact-hit="false" default-menu-include="false">
15
16 <parameter name="jsonrpc"/>
17 <parameter name="id"/>
18 <parameter name="method"/>
19 <parameter name="params"/>
20
21
22
23 <actions>
24 <script><![CDATA[
25 import groovy.json.JsonBuilder
26 import groovy.json.JsonSlurper
27 import java.util.UUID
28
29 // DEBUG: Log initial request details
30 ec.logger.info("=== MCP SCREEN REQUEST START ===")
31 ec.logger.info("MCP Screen Request - Method: ${ec.web?.request?.method}, ID: ${id}")
32 ec.logger.info("MCP Screen Request - Params: ${params}")
33 ec.logger.info("MCP Screen Request - User: ${ec.user.username}, UserID: ${ec.user.userId}")
34 ec.logger.info("MCP Screen Request - Current Time: ${ec.user.getNowTimestamp()}")
35
36 // Check MCP protocol version header
37 def protocolVersion = ec.web.request.getHeader("MCP-Protocol-Version")
38 if (!protocolVersion) {
39 protocolVersion = "2025-06-18" // Default to latest supported
40 }
41 ec.logger.info("MCP Protocol Version: ${protocolVersion}")
42
43 // Validate HTTP method - only POST for JSON-RPC, GET for SSE streams
44 def httpMethod = ec.web?.request?.method
45 ec.logger.info("Validating HTTP method: ${httpMethod}")
46
47 // Handle GET requests for SSE streams
48 if (httpMethod == "GET") {
49 ec.logger.info("GET request detected - checking for SSE support")
50 def acceptHeader = ec.web?.request?.getHeader("Accept")
51 ec.logger.info("GET Accept header: ${acceptHeader}")
52
53 if (acceptHeader?.contains("text/event-stream")) {
54 ec.logger.info("Client wants SSE stream - starting SSE")
55 // Set SSE headers
56 ec.web.response.setContentType("text/event-stream")
57 ec.web.response.setCharacterEncoding("UTF-8")
58 ec.web.response.setHeader("Cache-Control", "no-cache")
59 ec.web.response.setHeader("Connection", "keep-alive")
60 ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
61
62 def writer = ec.web.response.writer
63
64 try {
65 // Send initial connection event
66 writer.write("event: connected\n")
67 writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
68 writer.write("\n")
69 writer.flush()
70
71 // Keep connection alive with periodic pings
72 def count = 0
73 while (count < 30) { // Keep alive for ~30 seconds
74 Thread.sleep(1000)
75 writer.write("event: ping\n")
76 writer.write("data: {\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
77 writer.write("\n")
78 writer.flush()
79 count++
80 }
81
82 } catch (Exception e) {
83 ec.logger.warn("SSE stream interrupted: ${e.message}")
84 } finally {
85 writer.close()
86 }
87 return
88 } else {
89 // For GET requests without SSE Accept, return 405 as expected by MCP spec
90 ec.logger.info("GET request without SSE Accept - returning 405 Method Not Allowed")
91 throw new org.moqui.BaseException("Method Not Allowed. Use POST for JSON-RPC requests.")
92 }
93 }
94
95 if (httpMethod != "POST") {
96 ec.logger.warn("Invalid HTTP method: ${httpMethod}, expected POST")
97 throw new org.moqui.BaseException("Method Not Allowed. Use POST for JSON-RPC requests.")
98 }
99 ec.logger.info("HTTP method validation passed")
100 ]]></script>
101 </actions>
102
103 <transition name="rpc" method="post" require-session-token="false">
104 <actions>
105 <script><![CDATA[
106 import groovy.json.JsonBuilder
107 import groovy.json.JsonSlurper
108 import java.util.UUID
109
110 // DEBUG: Log transition request details
111 ec.logger.info("=== MCP RPC TRANSITION START ===")
112 ec.logger.info("MCP RPC Request - ID: ${id}")
113 ec.logger.info("MCP RPC Request - Params: ${params}")
114 ec.logger.info("MCP RPC Request - User: ${ec.user.username}, UserID: ${ec.user.userId}")
115 ec.logger.info("MCP RPC Request - Current Time: ${ec.user.getNowTimestamp()}")
116
117 // Check MCP protocol version header
118 def protocolVersion = ec.web.request.getHeader("MCP-Protocol-Version")
119 if (!protocolVersion) {
120 protocolVersion = "2025-06-18" // Default to latest supported
121 }
122 ec.logger.info("MCP Protocol Version: ${protocolVersion}")
123
124 // Validate Content-Type header for POST requests
125 def contentType = ec.web?.request?.getContentType()
126 ec.logger.info("Validating Content-Type: ${contentType}")
127 if (!contentType?.contains("application/json")) {
128 ec.logger.warn("Invalid Content-Type: ${contentType}, expected application/json or application/json-rpc")
129 ec.web?.response?.setStatus(415) // Unsupported Media Type
130 ec.web?.response?.setContentType("text/plain")
131 ec.web?.response?.getWriter()?.write("Content-Type must be application/json or application/json-rpc for JSON-RPC messages")
132 ec.web?.response?.getWriter()?.flush()
133 return
134 }
135 ec.logger.info("Content-Type validation passed")
136
137 // Validate Accept header - prioritize application/json over text/event-stream when both present
138 def acceptHeader = ec.web?.request?.getHeader("Accept")
139 ec.logger.info("Validating Accept header: ${acceptHeader}")
140 def wantsStreaming = false
141 if (acceptHeader) {
142 if (acceptHeader.contains("text/event-stream") && !acceptHeader.contains("application/json")) {
143 wantsStreaming = true
144 }
145 // If both are present, prefer application/json (don't set wantsStreaming)
146 }
147 ec.logger.info("Client wants streaming: ${wantsStreaming} (Accept: ${acceptHeader})")
148
149 // Validate Origin header for DNS rebinding protection
150 def originHeader = ec.web?.request?.getHeader("Origin")
151 ec.logger.info("Checking Origin header: ${originHeader}")
152 if (originHeader) {
153 try {
154 def originValid = ec.service.sync("McpServices.validate#Origin", [origin: originHeader]).isValid
155 ec.logger.info("Origin validation result: ${originValid}")
156 if (!originValid) {
157 ec.logger.warn("Invalid Origin header rejected: ${originHeader}")
158 ec.web?.response?.setStatus(403) // Forbidden
159 ec.web?.response?.setContentType("text/plain")
160 ec.web?.response?.getWriter()?.write("Invalid Origin header")
161 ec.web?.response?.getWriter()?.flush()
162 return
163 }
164 } catch (Exception e) {
165 ec.logger.error("Error during Origin validation", e)
166 ec.web?.response?.setStatus(500) // Internal Server Error
167 ec.web?.response?.setContentType("text/plain")
168 ec.web?.response?.getWriter()?.write("Error during Origin validation: ${e.message}")
169 ec.web?.response?.getWriter()?.flush()
170 return
171 }
172 } else {
173 ec.logger.info("No Origin header present")
174 }
175
176 // Set protocol version header on all responses
177 ec.web?.response?.setHeader("MCP-Protocol-Version", protocolVersion)
178 ec.logger.info("Set MCP protocol version header")
179
180 // Handle session management
181 def sessionId = ec.web?.request?.getHeader("Mcp-Session-Id")
182 def isInitialize = (method == "initialize")
183 ec.logger.info("Session management - SessionId: ${sessionId}, IsInitialize: ${isInitialize}")
184
185 if (!isInitialize && !sessionId) {
186 ec.logger.warn("Missing session ID for non-initialization request")
187 ec.web?.response?.setStatus(400) // Bad Request
188 ec.web?.response?.setContentType("text/plain")
189 ec.web?.response?.getWriter()?.write("Mcp-Session-Id header required for non-initialization requests")
190 ec.web?.response?.getWriter()?.flush()
191 return
192 }
193
194 // Generate new session ID for initialization
195 if (isInitialize) {
196 def newSessionId = UUID.randomUUID().toString()
197 ec.web?.response?.setHeader("Mcp-Session-Id", newSessionId)
198 ec.logger.info("Generated new session ID: ${newSessionId}")
199 }
200
201 // Parse JSON-RPC request body if not already in parameters
202 if (!jsonrpc && !method) {
203 def requestBody = ec.web.request.getInputStream()?.getText()
204 if (requestBody) {
205 def jsonSlurper = new JsonSlurper()
206 def jsonRequest = jsonSlurper.parseText(requestBody)
207 jsonrpc = jsonRequest.jsonrpc
208 id = jsonRequest.id
209 method = jsonRequest.method
210 params = jsonRequest.params
211 }
212 }
213
214 // Validate JSON-RPC version
215 ec.logger.info("Validating JSON-RPC version: ${jsonrpc}")
216 if (jsonrpc && jsonrpc != "2.0") {
217 ec.logger.warn("Invalid JSON-RPC version: ${jsonrpc}")
218 def errorResponse = new JsonBuilder([
219 jsonrpc: "2.0",
220 error: [
221 code: -32600,
222 message: "Invalid Request: Only JSON-RPC 2.0 supported"
223 ],
224 id: id
225 ]).toString()
226
227 if (wantsStreaming) {
228 sendSseError(ec, errorResponse, protocolVersion)
229 } else {
230 ec.web.sendJsonResponse(errorResponse)
231 }
232 return
233 }
234 ec.logger.info("JSON-RPC version validation passed")
235
236 def result = null
237 def error = null
238
239 try {
240 // Route to appropriate MCP service using correct service names
241 def serviceName = null
242 ec.logger.info("Mapping method '${method}' to service name")
243 switch (method) {
244 case "mcp#Ping":
245 case "ping":
246 serviceName = "McpServices.mcp#Ping"
247 ec.logger.info("Mapped to service: ${serviceName}")
248 break
249 case "initialize":
250 case "mcp#Initialize":
251 serviceName = "McpServices.mcp#Initialize"
252 ec.logger.info("Mapped to service: ${serviceName}")
253 break
254 case "tools/list":
255 case "mcp#ToolsList":
256 serviceName = "McpServices.mcp#ToolsList"
257 ec.logger.info("Mapped to service: ${serviceName}")
258 break
259 case "tools/call":
260 case "mcp#ToolsCall":
261 serviceName = "McpServices.mcp#ToolsCall"
262 ec.logger.info("Mapped to service: ${serviceName}")
263 break
264 case "resources/list":
265 case "mcp#ResourcesList":
266 serviceName = "McpServices.mcp#ResourcesList"
267 ec.logger.info("Mapped to service: ${serviceName}")
268 break
269 case "resources/read":
270 case "mcp#ResourcesRead":
271 serviceName = "McpServices.mcp#ResourcesRead"
272 ec.logger.info("Mapped to service: ${serviceName}")
273 break
274 default:
275 ec.logger.warn("Unknown method: ${method}")
276 error = [
277 code: -32601,
278 message: "Method not found: ${method}"
279 ]
280 }
281
282 if (serviceName && !error) {
283 ec.logger.info("Calling service: ${serviceName} with params: ${params}")
284 // Check if service exists before calling
285 if (!ec.service.isServiceDefined(serviceName)) {
286 ec.logger.error("Service not defined: ${serviceName}")
287 error = [
288 code: -32601,
289 message: "Service not found: ${serviceName}"
290 ]
291 } else {
292 // Call the actual MCP service
293 def serviceStartTime = System.currentTimeMillis()
294 result = ec.service.sync().name(serviceName).parameters(params ?: [:]).call()
295 def serviceEndTime = System.currentTimeMillis()
296 ec.logger.info("Service ${serviceName} completed in ${serviceEndTime - serviceStartTime}ms")
297 ec.logger.info("Service result type: ${result?.getClass()?.getSimpleName()}")
298 ec.logger.info("Service result: ${result}")
299 }
300 }
301
302 } catch (Exception e) {
303 ec.logger.error("MCP request error for method ${method}", e)
304 ec.logger.error("Exception details: ${e.getClass().getName()}: ${e.message}")
305 ec.logger.error("Exception stack trace: ${e.getStackTrace()}")
306 error = [
307 code: -32603,
308 message: "Internal error: ${e.message}"
309 ]
310 }
311
312 // Build JSON-RPC response
313 ec.logger.info("Building JSON-RPC response")
314 def responseObj = [
315 jsonrpc: "2.0",
316 id: id
317 ]
318
319 if (error) {
320 responseObj.error = error
321 ec.logger.info("Response includes error: ${error}")
322 } else {
323 responseObj.result = result
324 ec.logger.info("Response includes result")
325 }
326
327 def jsonResponse = new JsonBuilder(responseObj).toString()
328 ec.logger.info("Built JSON response: ${jsonResponse}")
329 ec.logger.info("JSON response length: ${jsonResponse.length()}")
330
331 // Handle both JSON-RPC 2.0 and SSE responses
332 if (wantsStreaming) {
333 ec.logger.info("Creating SSE response")
334 sendSseResponse(ec, responseObj, protocolVersion)
335 } else {
336 ec.logger.info("Creating JSON-RPC 2.0 response")
337 ec.web.sendJsonResponse(jsonResponse)
338 ec.logger.info("Sent JSON-RPC response, length: ${jsonResponse.length()}")
339 }
340
341 ec.logger.info("=== MCP SCREEN REQUEST END ===")
342 ]]></script>
343 </actions>
344 <default-response type="none"/>
345 </transition>
346
347 <actions>
348 <!-- SSE Helper Functions -->
349 <script><![CDATA[
350 def handleSseStream(ec, protocolVersion) {
351 // Set SSE headers
352 ec.web.response.setContentType("text/event-stream")
353 ec.web.response.setCharacterEncoding("UTF-8")
354 ec.web.response.setHeader("Cache-Control", "no-cache")
355 ec.web.response.setHeader("Connection", "keep-alive")
356 ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
357
358 def writer = ec.web.response.writer
359
360 try {
361 // Send initial connection event
362 writer.write("event: connected\n")
363 writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
364 writer.write("\n")
365 writer.flush()
366
367 // Keep connection alive with periodic pings
368 def count = 0
369 while (count < 30) { // Keep alive for ~30 seconds
370 Thread.sleep(1000)
371 writer.write("event: ping\n")
372 writer.write("data: {\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
373 writer.write("\n")
374 writer.flush()
375 count++
376 }
377
378 } catch (Exception e) {
379 ec.logger.warn("SSE stream interrupted: ${e.message}")
380 } finally {
381 writer.close()
382 }
383 }
384
385 def sendSseResponse(ec, responseObj, protocolVersion) {
386 // Set SSE headers
387 ec.web.response.setContentType("text/event-stream")
388 ec.web.response.setCharacterEncoding("UTF-8")
389 ec.web.response.setHeader("Cache-Control", "no-cache")
390 ec.web.response.setHeader("Connection", "keep-alive")
391 ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
392
393 def writer = ec.web.response.writer
394 def jsonBuilder = new JsonBuilder(responseObj)
395
396 try {
397 // Send response as SSE event
398 writer.write("event: response\n")
399 writer.write("data: ${jsonBuilder.toString()}\n")
400 writer.write("\n")
401 writer.flush()
402
403 } catch (Exception e) {
404 ec.logger.error("Error sending SSE response: ${e.message}")
405 } finally {
406 writer.close()
407 }
408 }
409
410 def sendSseError(ec, errorResponse, protocolVersion) {
411 // Set SSE headers
412 ec.web.response.setContentType("text/event-stream")
413 ec.web.response.setCharacterEncoding("UTF-8")
414 ec.web.response.setHeader("Cache-Control", "no-cache")
415 ec.web.response.setHeader("Connection", "keep-alive")
416 ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
417
418 def writer = ec.web.response.writer
419
420 try {
421 // Send error as SSE event
422 writer.write("event: error\n")
423 writer.write("data: ${errorResponse}\n")
424 writer.write("\n")
425 writer.flush()
426
427 } catch (Exception e) {
428 ec.logger.error("Error sending SSE error: ${e.message}")
429 } finally {
430 writer.close()
431 }
432 }
433 ]]></script>
434 </actions>
435
436
437
438 <widgets>
439 <!-- This screen should never render widgets - it handles JSON-RPC requests directly -->
440 </widgets>
441 </screen>
...\ No newline at end of file ...\ No newline at end of file
...@@ -257,10 +257,14 @@ ...@@ -257,10 +257,14 @@
257 def userId = ec.user.userId 257 def userId = ec.user.userId
258 def userAccountId = userId ? userId : null 258 def userAccountId = userId ? userId : null
259 259
260 // Build server capabilities 260 // Get user-specific tools and resources
261 def toolsResult = ec.service.sync().name("org.moqui.mcp.McpServices.mcp#ToolsList").parameters([:]).call()
262 def resourcesResult = ec.service.sync().name("org.moqui.mcp.McpServices.mcp#ResourcesList").parameters([:]).call()
263
264 // Build server capabilities based on what user can access
261 def serverCapabilities = [ 265 def serverCapabilities = [
262 tools: [:], 266 tools: toolsResult?.result?.tools ? [listChanged: true] : [:],
263 resources: [:], 267 resources: resourcesResult?.result?.resources ? [subscribe: true, listChanged: true] : [:],
264 logging: [:] 268 logging: [:]
265 ] 269 ]
266 270
...@@ -274,8 +278,10 @@ ...@@ -274,8 +278,10 @@
274 protocolVersion: "2025-06-18", 278 protocolVersion: "2025-06-18",
275 capabilities: serverCapabilities, 279 capabilities: serverCapabilities,
276 serverInfo: serverInfo, 280 serverInfo: serverInfo,
277 instructions: "This server provides access to Moqui ERP services and entities through MCP. Use tools/list to discover available operations." 281 instructions: "This server provides access to Moqui ERP services and entities through MCP. Tools and resources are filtered based on your permissions."
278 ] 282 ]
283
284 ec.logger.info("MCP Initialize for user ${userId}: ${toolsResult?.result?.tools?.size() ?: 0} tools, ${resourcesResult?.result?.resources?.size() ?: 0} resources")
279 ]]></script> 285 ]]></script>
280 </actions> 286 </actions>
281 </service> 287 </service>
...@@ -321,13 +327,27 @@ ...@@ -321,13 +327,27 @@
321 ] 327 ]
322 ] 328 ]
323 329
330 // Add service metadata to help LLM
331 if (serviceInfo.verb && serviceInfo.noun) {
332 tool.description += " (${serviceInfo.verb}:${serviceInfo.noun})"
333 }
334
324 // Convert service parameters to JSON Schema 335 // Convert service parameters to JSON Schema
325 def inParamNames = serviceInfo.getInParameterNames() 336 def inParamNames = serviceInfo.getInParameterNames()
326 for (paramName in inParamNames) { 337 for (paramName in inParamNames) {
327 def paramInfo = serviceInfo.getInParameter(paramName) 338 def paramInfo = serviceInfo.getInParameter(paramName)
339 def paramDesc = paramInfo.description ?: ""
340
341 // Add type information to description for LLM
342 if (!paramDesc) {
343 paramDesc = "Parameter of type ${paramInfo.type}"
344 } else {
345 paramDesc += " (type: ${paramInfo.type})"
346 }
347
328 tool.inputSchema.properties[paramName] = [ 348 tool.inputSchema.properties[paramName] = [
329 type: convertMoquiTypeToJsonSchemaType(paramInfo.type), 349 type: convertMoquiTypeToJsonSchemaType(paramInfo.type),
330 description: paramInfo.description ?: "" 350 description: paramDesc
331 ] 351 ]
332 352
333 if (paramInfo.required) { 353 if (paramInfo.required) {
...@@ -474,10 +494,15 @@ ...@@ -474,10 +494,15 @@
474 def resource = [ 494 def resource = [
475 uri: "entity://${entityName}", 495 uri: "entity://${entityName}",
476 name: entityName, 496 name: entityName,
477 description: "Moqui entity: ${entityName}", 497 description: entityInfo.description ?: "Moqui entity: ${entityName}",
478 mimeType: "application/json" 498 mimeType: "application/json"
479 ] 499 ]
480 500
501 // Add entity metadata to help LLM
502 if (entityInfo.packageName) {
503 resource.description += " (package: ${entityInfo.packageName})"
504 }
505
481 availableResources << resource 506 availableResources << resource
482 507
483 } catch (Exception e) { 508 } catch (Exception e) {
...@@ -536,6 +561,9 @@ ...@@ -536,6 +561,9 @@
536 561
537 def startTime = System.currentTimeMillis() 562 def startTime = System.currentTimeMillis()
538 try { 563 try {
564 // Get entity definition for field descriptions
565 def entityDef = ec.entity.getEntityDefinition(entityName)
566
539 // Query entity data (limited to prevent large responses) 567 // Query entity data (limited to prevent large responses)
540 def entityList = ec.entity.find(entityName) 568 def entityList = ec.entity.find(entityName)
541 .limit(100) 569 .limit(100)
...@@ -543,6 +571,18 @@ ...@@ -543,6 +571,18 @@
543 571
544 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 572 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
545 573
574 // Build field info for LLM
575 def fieldInfo = []
576 entityDef.allFieldInfoList.each { field ->
577 fieldInfo << [
578 name: field.name,
579 type: field.type,
580 description: field.description ?: "",
581 isPk: field.isPk,
582 required: field.notNull
583 ]
584 }
585
546 // Convert to MCP resource content 586 // Convert to MCP resource content
547 def contents = [ 587 def contents = [
548 [ 588 [
...@@ -550,7 +590,10 @@ ...@@ -550,7 +590,10 @@
550 mimeType: "application/json", 590 mimeType: "application/json",
551 text: new JsonBuilder([ 591 text: new JsonBuilder([
552 entityName: entityName, 592 entityName: entityName,
593 description: entityDef.description ?: "",
594 packageName: entityDef.packageName,
553 recordCount: entityList.size(), 595 recordCount: entityList.size(),
596 fields: fieldInfo,
554 data: entityList 597 data: entityList
555 ]).toString() 598 ]).toString()
556 ] 599 ]
......
1 <?xml version="1.0" encoding="UTF-8"?>
2 <!-- This software is in the public domain under CC0 1.0 Universal plus a
3 Grant of Patent License.
4
5 To the extent possible under law, author(s) have dedicated all
6 copyright and related and neighboring rights to this software to the
7 public domain worldwide. This software is distributed without any warranty.
8
9 You should have received a copy of the CC0 Public Domain Dedication
10 along with this software (see the LICENSE.md file). If not, see
11 <https://creativecommons.org/publicdomain/zero/1.0/>. -->
12
13 <resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/rest-api-3.xsd"
14 name="mcp" displayName="MCP API" version="2.0.0"
15 description="MCP API services for Moqui integration - NOTE: Main functionality moved to screen/webapp.xml">
16
17 <!-- NOTE: Main MCP functionality moved to screen/webapp.xml for unified JSON-RPC and SSE handling -->
18 <!-- Keeping only basic GET endpoints for health checks and debugging -->
19
20 <resource name="rpc">
21 <method type="get">
22 <service name="McpServices.mcp#Ping"/>
23 </method>
24 <method type="get" path="debug">
25 <service name="McpServices.debug#ComponentStatus"/>
26 </method>
27 </resource>
28
29 </resource>
1 /*
2 * This software is in the public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 *
5 * To the extent possible under law, author(s) have dedicated all
6 * copyright and related and neighboring rights to this software to the
7 * public domain worldwide. This software is distributed without any
8 * warranty.
9 *
10 * You should have received a copy of the CC0 Public Domain Dedication
11 * along with this software (see the LICENSE.md file). If not, see
12 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13 */
14 package org.moqui.mcp
15
16 import groovy.json.JsonSlurper
17 import org.moqui.impl.context.ExecutionContextFactoryImpl
18 import org.moqui.context.ArtifactAuthorizationException
19 import org.moqui.context.ArtifactTarpitException
20 import org.moqui.impl.context.ExecutionContextImpl
21 import org.slf4j.Logger
22 import org.slf4j.LoggerFactory
23
24 import javax.servlet.ServletConfig
25 import javax.servlet.ServletException
26 import javax.servlet.http.HttpServlet
27 import javax.servlet.http.HttpServletRequest
28 import javax.servlet.http.HttpServletResponse
29 import java.util.concurrent.ConcurrentHashMap
30 import java.util.concurrent.atomic.AtomicBoolean
31 import java.util.UUID
32
33 /**
34 * Enhanced MCP Servlet with proper SSE handling inspired by HttpServletSseServerTransportProvider
35 * This implementation provides better SSE support and session management.
36 */
37 class EnhancedMcpServlet extends HttpServlet {
38 protected final static Logger logger = LoggerFactory.getLogger(EnhancedMcpServlet.class)
39
40 private JsonSlurper jsonSlurper = new JsonSlurper()
41
42 // Session management for SSE connections
43 private final Map<String, McpSession> sessions = new ConcurrentHashMap<>()
44 private final AtomicBoolean isClosing = new AtomicBoolean(false)
45
46 @Override
47 void init(ServletConfig config) throws ServletException {
48 super.init(config)
49 String webappName = config.getInitParameter("moqui-name") ?:
50 config.getServletContext().getInitParameter("moqui-name")
51 logger.info("EnhancedMcpServlet initialized for webapp ${webappName}")
52 }
53
54 @Override
55 void service(HttpServletRequest request, HttpServletResponse response)
56 throws ServletException, IOException {
57
58 ExecutionContextFactoryImpl ecfi =
59 (ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory")
60 String webappName = getInitParameter("moqui-name") ?:
61 getServletContext().getInitParameter("moqui-name")
62
63 if (ecfi == null || webappName == null) {
64 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
65 "System is initializing, try again soon.")
66 return
67 }
68
69 // Handle CORS
70 if (handleCors(request, response, webappName, ecfi)) return
71
72 long startTime = System.currentTimeMillis()
73
74 if (logger.traceEnabled) {
75 logger.trace("Start Enhanced MCP request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]")
76 }
77
78 ExecutionContextImpl activeEc = ecfi.activeContext.get()
79 if (activeEc != null) {
80 logger.warn("In EnhancedMcpServlet.service there is already an ExecutionContext for user ${activeEc.user.username}")
81 activeEc.destroy()
82 }
83
84 ExecutionContextImpl ec = ecfi.getEci()
85
86 try {
87 // Initialize web facade for authentication
88 ec.initWebFacade(webappName, request, response)
89
90 logger.info("Enhanced MCP Request authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}")
91
92 // If no user authenticated, try to authenticate as admin for MCP requests
93 if (!ec.user?.userId) {
94 logger.info("No user authenticated, attempting admin login for Enhanced MCP")
95 try {
96 ec.user.loginUser("admin", "admin")
97 logger.info("Enhanced MCP Admin login successful, user: ${ec.user?.username}")
98 } catch (Exception e) {
99 logger.warn("Enhanced MCP Admin login failed: ${e.message}")
100 }
101 }
102
103 // Route based on request method and path
104 String requestURI = request.getRequestURI()
105 String method = request.getMethod()
106
107 if ("GET".equals(method) && requestURI.endsWith("/sse")) {
108 handleSseConnection(request, response, ec)
109 } else if ("POST".equals(method) && requestURI.endsWith("/message")) {
110 handleMessage(request, response, ec)
111 } else {
112 // Fallback to JSON-RPC handling
113 handleJsonRpc(request, response, ec)
114 }
115
116 } catch (ArtifactAuthorizationException e) {
117 logger.warn("Enhanced MCP Access Forbidden (no authz): " + e.message)
118 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
119 response.setContentType("application/json")
120 response.writer.write(groovy.json.JsonOutput.toJson([
121 jsonrpc: "2.0",
122 error: [code: -32001, message: "Access Forbidden: " + e.message],
123 id: null
124 ]))
125 } catch (ArtifactTarpitException e) {
126 logger.warn("Enhanced MCP Too Many Requests (tarpit): " + e.message)
127 response.setStatus(429)
128 if (e.getRetryAfterSeconds()) {
129 response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
130 }
131 response.setContentType("application/json")
132 response.writer.write(groovy.json.JsonOutput.toJson([
133 jsonrpc: "2.0",
134 error: [code: -32002, message: "Too Many Requests: " + e.message],
135 id: null
136 ]))
137 } catch (Throwable t) {
138 logger.error("Error in Enhanced MCP request", t)
139 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
140 response.setContentType("application/json")
141 response.writer.write(groovy.json.JsonOutput.toJson([
142 jsonrpc: "2.0",
143 error: [code: -32603, message: "Internal error: " + t.message],
144 id: null
145 ]))
146 } finally {
147 ec.destroy()
148 }
149 }
150
151 private void handleSseConnection(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
152 throws IOException {
153
154 if (isClosing.get()) {
155 response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down")
156 return
157 }
158
159 logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
160
161 // Set SSE headers
162 response.setContentType("text/event-stream")
163 response.setCharacterEncoding("UTF-8")
164 response.setHeader("Cache-Control", "no-cache")
165 response.setHeader("Connection", "keep-alive")
166 response.setHeader("Access-Control-Allow-Origin", "*")
167
168 String sessionId = UUID.randomUUID().toString()
169
170 // Create session transport
171 McpSession session = new McpSession(sessionId, response.writer)
172 sessions.put(sessionId, session)
173
174 try {
175 // Send initial connection event with endpoint info
176 sendSseEvent(response.writer, "endpoint", "/mcp-sse/message?sessionId=" + sessionId)
177
178 // Send initial resources list
179 def resourcesResult = processMcpMethod("resources/list", [:], ec)
180 sendSseEvent(response.writer, "resources", groovy.json.JsonOutput.toJson(resourcesResult))
181
182 // Send initial tools list
183 def toolsResult = processMcpMethod("tools/list", [:], ec)
184 sendSseEvent(response.writer, "tools", groovy.json.JsonOutput.toJson(toolsResult))
185
186 // Keep connection alive with periodic pings
187 int pingCount = 0
188 while (!response.isCommitted() && !isClosing.get() && pingCount < 60) { // 5 minutes max
189 Thread.sleep(5000) // Wait 5 seconds
190
191 if (!response.isCommitted() && !isClosing.get()) {
192 sendSseEvent(response.writer, "ping", groovy.json.JsonOutput.toJson([
193 type: "ping",
194 count: pingCount,
195 timestamp: System.currentTimeMillis()
196 ]))
197 pingCount++
198 }
199 }
200
201 } catch (Exception e) {
202 logger.warn("Enhanced SSE connection interrupted: ${e.message}")
203 } finally {
204 // Clean up session
205 sessions.remove(sessionId)
206 try {
207 sendSseEvent(response.writer, "close", groovy.json.JsonOutput.toJson([
208 type: "disconnected",
209 timestamp: System.currentTimeMillis()
210 ]))
211 } catch (Exception e) {
212 // Ignore errors during cleanup
213 }
214 }
215 }
216
217 private void handleMessage(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
218 throws IOException {
219
220 if (isClosing.get()) {
221 response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down")
222 return
223 }
224
225 // Get session ID from request parameter
226 String sessionId = request.getParameter("sessionId")
227 if (sessionId == null) {
228 response.setContentType("application/json")
229 response.setCharacterEncoding("UTF-8")
230 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
231 response.writer.write(groovy.json.JsonOutput.toJson([
232 error: "Session ID missing in message endpoint"
233 ]))
234 return
235 }
236
237 // Get session from sessions map
238 McpSession session = sessions.get(sessionId)
239 if (session == null) {
240 response.setContentType("application/json")
241 response.setCharacterEncoding("UTF-8")
242 response.setStatus(HttpServletResponse.SC_NOT_FOUND)
243 response.writer.write(groovy.json.JsonOutput.toJson([
244 error: "Session not found: " + sessionId
245 ]))
246 return
247 }
248
249 try {
250 // Read request body
251 BufferedReader reader = request.getReader()
252 StringBuilder body = new StringBuilder()
253 String line
254 while ((line = reader.readLine()) != null) {
255 body.append(line)
256 }
257
258 // Parse JSON-RPC message
259 def rpcRequest = jsonSlurper.parseText(body.toString())
260
261 // Process the method
262 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec)
263
264 // Send response via SSE to the specific session
265 sendSseEvent(session.writer, "response", groovy.json.JsonOutput.toJson([
266 id: rpcRequest.id,
267 result: result
268 ]))
269
270 response.setStatus(HttpServletResponse.SC_OK)
271
272 } catch (Exception e) {
273 logger.error("Error processing message: ${e.message}")
274 response.setContentType("application/json")
275 response.setCharacterEncoding("UTF-8")
276 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
277 response.writer.write(groovy.json.JsonOutput.toJson([
278 error: e.message
279 ]))
280 }
281 }
282
283 private void handleJsonRpc(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
284 throws IOException {
285
286 String method = request.getMethod()
287 String acceptHeader = request.getHeader("Accept")
288 String contentType = request.getContentType()
289
290 logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}")
291
292 // Handle POST requests for JSON-RPC
293 if (!"POST".equals(method)) {
294 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
295 response.setContentType("application/json")
296 response.writer.write(groovy.json.JsonOutput.toJson([
297 jsonrpc: "2.0",
298 error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."],
299 id: null
300 ]))
301 return
302 }
303
304 // Read and parse JSON-RPC request
305 String requestBody
306 try {
307 BufferedReader reader = request.getReader()
308 StringBuilder body = new StringBuilder()
309 String line
310 while ((line = reader.readLine()) != null) {
311 body.append(line)
312 }
313 requestBody = body.toString()
314
315 } catch (IOException e) {
316 logger.error("Failed to read request body: ${e.message}")
317 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
318 response.setContentType("application/json")
319 response.writer.write(groovy.json.JsonOutput.toJson([
320 jsonrpc: "2.0",
321 error: [code: -32700, message: "Failed to read request body: " + e.message],
322 id: null
323 ]))
324 return
325 }
326
327 if (!requestBody) {
328 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
329 response.setContentType("application/json")
330 response.writer.write(groovy.json.JsonOutput.toJson([
331 jsonrpc: "2.0",
332 error: [code: -32602, message: "Empty request body"],
333 id: null
334 ]))
335 return
336 }
337
338 def rpcRequest
339 try {
340 rpcRequest = jsonSlurper.parseText(requestBody)
341 } catch (Exception e) {
342 logger.error("Failed to parse JSON-RPC request: ${e.message}")
343 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
344 response.setContentType("application/json")
345 response.writer.write(groovy.json.JsonOutput.toJson([
346 jsonrpc: "2.0",
347 error: [code: -32700, message: "Invalid JSON: " + e.message],
348 id: null
349 ]))
350 return
351 }
352
353 // Validate JSON-RPC 2.0 structure
354 if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
355 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
356 response.setContentType("application/json")
357 response.writer.write(groovy.json.JsonOutput.toJson([
358 jsonrpc: "2.0",
359 error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
360 id: null
361 ]))
362 return
363 }
364
365 // Process MCP method using Moqui services
366 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec)
367
368 // Build JSON-RPC response
369 def rpcResponse = [
370 jsonrpc: "2.0",
371 id: rpcRequest.id,
372 result: result
373 ]
374
375 response.setContentType("application/json")
376 response.setCharacterEncoding("UTF-8")
377 response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
378 }
379
380 private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec) {
381 logger.info("Enhanced METHOD: ${method} with params: ${params}")
382 switch (method) {
383 case "initialize":
384 return callMcpService("mcp#Initialize", params, ec)
385 case "ping":
386 return callMcpService("mcp#Ping", params, ec)
387 case "tools/list":
388 return callMcpService("mcp#ToolsList", params, ec)
389 case "tools/call":
390 return callMcpService("mcp#ToolsCall", params, ec)
391 case "resources/list":
392 return callMcpService("mcp#ResourcesList", params, ec)
393 case "resources/read":
394 return callMcpService("mcp#ResourcesRead", params, ec)
395 default:
396 throw new IllegalArgumentException("Unknown MCP method: ${method}")
397 }
398 }
399
400 private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) {
401 logger.info("Enhanced Calling MCP service: ${serviceName} with params: ${params}")
402
403 try {
404 def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}")
405 .parameters(params ?: [:])
406 .call()
407
408 logger.info("Enhanced MCP service ${serviceName} result: ${result}")
409 return result.result
410 } catch (Exception e) {
411 logger.error("Error calling Enhanced MCP service ${serviceName}", e)
412 throw e
413 }
414 }
415
416 private void sendSseEvent(PrintWriter writer, String eventType, String data) throws IOException {
417 writer.write("event: " + eventType + "\n")
418 writer.write("data: " + data + "\n\n")
419 writer.flush()
420
421 if (writer.checkError()) {
422 throw new IOException("Client disconnected")
423 }
424 }
425
426 // CORS handling based on MoquiServlet pattern
427 private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) {
428 String originHeader = request.getHeader("Origin")
429 if (originHeader) {
430 response.setHeader("Access-Control-Allow-Origin", originHeader)
431 response.setHeader("Access-Control-Allow-Credentials", "true")
432 }
433
434 String methodHeader = request.getHeader("Access-Control-Request-Method")
435 if (methodHeader) {
436 response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
437 response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
438 response.setHeader("Access-Control-Max-Age", "3600")
439 return true
440 }
441 return false
442 }
443
444 @Override
445 void destroy() {
446 logger.info("Destroying EnhancedMcpServlet")
447 isClosing.set(true)
448
449 // Close all active sessions
450 sessions.values().each { session ->
451 try {
452 session.close()
453 } catch (Exception e) {
454 logger.warn("Error closing session: ${e.message}")
455 }
456 }
457 sessions.clear()
458
459 super.destroy()
460 }
461
462 /**
463 * Simple session class for managing MCP SSE connections
464 */
465 static class McpSession {
466 String sessionId
467 PrintWriter writer
468 Date createdAt
469
470 McpSession(String sessionId, PrintWriter writer) {
471 this.sessionId = sessionId
472 this.writer = writer
473 this.createdAt = new Date()
474 }
475
476 void close() {
477 // Session cleanup logic
478 }
479 }
480 }
...\ No newline at end of file ...\ No newline at end of file
1 /*
2 * This software is in the public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 *
5 * To the extent possible under law, author(s) have dedicated all
6 * copyright and related and neighboring rights to this software to the
7 * public domain worldwide. This software is distributed without any
8 * warranty.
9 *
10 * You should have received a copy of the CC0 Public Domain Dedication
11 * along with this software (see the LICENSE.md file). If not, see
12 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13 */
14 package org.moqui.mcp
15
16 import org.slf4j.Logger
17 import org.slf4j.LoggerFactory
18
19 import javax.servlet.*
20 import javax.servlet.http.HttpServletRequest
21 import javax.servlet.http.HttpServletResponse
22
23 class McpFilter implements Filter {
24 protected final static Logger logger = LoggerFactory.getLogger(McpFilter.class)
25
26 private MoquiMcpServlet mcpServlet = new MoquiMcpServlet()
27
28 @Override
29 void init(FilterConfig filterConfig) throws ServletException {
30 logger.info("========== MCP FILTER INITIALIZED ==========")
31 // Initialize the servlet with filter config
32 mcpServlet.init(new ServletConfig() {
33 @Override
34 String getServletName() { return "McpFilter" }
35 @Override
36 ServletContext getServletContext() { return filterConfig.getServletContext() }
37 @Override
38 String getInitParameter(String name) { return filterConfig.getInitParameter(name) }
39 @Override
40 Enumeration<String> getInitParameterNames() { return filterConfig.getInitParameterNames() }
41 })
42 }
43
44 @Override
45 void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
46 throws IOException, ServletException {
47
48 logger.info("========== MCP FILTER DOFILTER CALLED ==========")
49
50 if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
51 HttpServletRequest httpRequest = (HttpServletRequest) request
52 HttpServletResponse httpResponse = (HttpServletResponse) response
53
54 // Check if this is an MCP request
55 String path = httpRequest.getRequestURI()
56 logger.info("========== MCP FILTER PATH: {} ==========", path)
57
58 if (path != null && path.contains("/mcpservlet")) {
59 logger.info("========== MCP FILTER HANDLING REQUEST ==========")
60 try {
61 // Handle MCP request directly, don't continue chain
62 mcpServlet.service(httpRequest, httpResponse)
63 return
64 } catch (Exception e) {
65 logger.error("Error in MCP filter", e)
66 // Send error response directly
67 httpResponse.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
68 httpResponse.setContentType("application/json")
69 httpResponse.writer.write(groovy.json.JsonOutput.toJson([
70 jsonrpc: "2.0",
71 error: [code: -32603, message: "Internal error: " + e.message],
72 id: null
73 ]))
74 return
75 }
76 }
77 }
78
79 // Not an MCP request, continue chain
80 chain.doFilter(request, response)
81 }
82
83 @Override
84 void destroy() {
85 mcpServlet.destroy()
86 logger.info("McpFilter destroyed")
87 }
88 }
...\ No newline at end of file ...\ No newline at end of file
1 /*
2 * This software is in the public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 *
5 * To the extent possible under law, author(s) have dedicated all
6 * copyright and related and neighboring rights to this software to the
7 * public domain worldwide. This software is distributed without any
8 * warranty.
9 *
10 * You should have received a copy of the CC0 Public Domain Dedication
11 * along with this software (see the LICENSE.md file). If not, see
12 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13 */
14 package org.moqui.mcp
15
16 import groovy.json.JsonSlurper
17 import org.moqui.impl.context.ExecutionContextFactoryImpl
18 import org.moqui.context.ArtifactAuthorizationException
19 import org.moqui.context.ArtifactTarpitException
20 import org.moqui.impl.context.ExecutionContextImpl
21 import org.slf4j.Logger
22 import org.slf4j.LoggerFactory
23
24 import javax.servlet.ServletConfig
25 import javax.servlet.ServletException
26 import javax.servlet.http.HttpServlet
27 import javax.servlet.http.HttpServletRequest
28 import javax.servlet.http.HttpServletResponse
29 class MoquiMcpServlet extends HttpServlet {
30 protected final static Logger logger = LoggerFactory.getLogger(MoquiMcpServlet.class)
31
32 private JsonSlurper jsonSlurper = new JsonSlurper()
33
34 @Override
35 void init(ServletConfig config) throws ServletException {
36 super.init(config)
37 String webappName = config.getInitParameter("moqui-name") ?:
38 config.getServletContext().getInitParameter("moqui-name")
39 logger.info("MoquiMcpServlet initialized for webapp ${webappName}")
40 }
41
42 @Override
43 void service(HttpServletRequest request, HttpServletResponse response)
44 throws ServletException, IOException {
45
46 ExecutionContextFactoryImpl ecfi =
47 (ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory")
48 String webappName = getInitParameter("moqui-name") ?:
49 getServletContext().getInitParameter("moqui-name")
50
51 if (ecfi == null || webappName == null) {
52 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
53 "System is initializing, try again soon.")
54 return
55 }
56
57 // Handle CORS (following Moqui pattern)
58 if (handleCors(request, response, webappName, ecfi)) return
59
60 long startTime = System.currentTimeMillis()
61
62 if (logger.traceEnabled) {
63 logger.trace("Start MCP request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]")
64 }
65
66 ExecutionContextImpl activeEc = ecfi.activeContext.get()
67 if (activeEc != null) {
68 logger.warn("In MoquiMcpServlet.service there is already an ExecutionContext for user ${activeEc.user.username}")
69 activeEc.destroy()
70 }
71
72 ExecutionContextImpl ec = ecfi.getEci()
73
74 try {
75 // Initialize web facade for authentication but avoid screen system
76 ec.initWebFacade(webappName, request, response)
77
78 logger.info("MCP Request authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}")
79
80 // If no user authenticated, try to authenticate as admin for MCP requests
81 if (!ec.user?.userId) {
82 logger.info("No user authenticated, attempting admin login for MCP")
83 try {
84 ec.user.loginUser("admin", "admin")
85 logger.info("MCP Admin login successful, user: ${ec.user?.username}")
86 } catch (Exception e) {
87 logger.warn("MCP Admin login failed: ${e.message}")
88 }
89 }
90
91 // Handle MCP JSON-RPC protocol
92 handleMcpRequest(request, response, ec)
93
94 } catch (ArtifactAuthorizationException e) {
95 logger.warn("MCP Access Forbidden (no authz): " + e.message)
96 // Handle error directly without sendError to avoid Moqui error screen interference
97 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
98 response.setContentType("application/json")
99 response.writer.write(groovy.json.JsonOutput.toJson([
100 jsonrpc: "2.0",
101 error: [code: -32001, message: "Access Forbidden: " + e.message],
102 id: null
103 ]))
104 } catch (ArtifactTarpitException e) {
105 logger.warn("MCP Too Many Requests (tarpit): " + e.message)
106 // Handle error directly without sendError to avoid Moqui error screen interference
107 response.setStatus(429)
108 if (e.getRetryAfterSeconds()) {
109 response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
110 }
111 response.setContentType("application/json")
112 response.writer.write(groovy.json.JsonOutput.toJson([
113 jsonrpc: "2.0",
114 error: [code: -32002, message: "Too Many Requests: " + e.message],
115 id: null
116 ]))
117 } catch (Throwable t) {
118 logger.error("Error in MCP request", t)
119 // Handle error directly without sendError to avoid Moqui error screen interference
120 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
121 response.setContentType("application/json")
122 response.writer.write(groovy.json.JsonOutput.toJson([
123 jsonrpc: "2.0",
124 error: [code: -32603, message: "Internal error: " + t.message],
125 id: null
126 ]))
127 } finally {
128 ec.destroy()
129 }
130 }
131
132 private void handleMcpRequest(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
133 throws IOException {
134
135 String method = request.getMethod()
136 String acceptHeader = request.getHeader("Accept")
137 String contentType = request.getContentType()
138 String userAgent = request.getHeader("User-Agent")
139
140 logger.info("MCP Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}, User-Agent: ${userAgent}")
141
142 // Handle SSE (Server-Sent Events) for streaming
143 if ("GET".equals(method) && acceptHeader != null && acceptHeader.contains("text/event-stream")) {
144 logger.info("Processing SSE request - GET with text/event-stream Accept header")
145 handleSseRequest(request, response, ec)
146 return
147 }
148
149 // Handle POST requests for JSON-RPC
150 if (!"POST".equals(method)) {
151 logger.warn("Rejecting non-POST request: ${method} - Only POST for JSON-RPC or GET with Accept: text/event-stream for SSE allowed")
152 // Handle error directly without sendError to avoid Moqui error screen interference
153 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
154 response.setContentType("application/json")
155 response.writer.write(groovy.json.JsonOutput.toJson([
156 jsonrpc: "2.0",
157 error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET with Accept: text/event-stream for SSE."],
158 id: null
159 ]))
160 return
161 }
162
163 // Read and parse JSON-RPC request following official MCP servlet pattern
164 logger.info("Processing JSON-RPC POST request")
165
166 String requestBody
167 try {
168 // Use BufferedReader pattern from official MCP servlet
169 BufferedReader reader = request.reader
170 StringBuilder body = new StringBuilder()
171 String line
172 while ((line = reader.readLine()) != null) {
173 body.append(line)
174 }
175
176 requestBody = body.toString()
177 logger.info("JSON-RPC request body (${requestBody.length()} chars): ${requestBody}")
178
179 } catch (IOException e) {
180 logger.error("Failed to read request body: ${e.message}")
181 // Handle error directly without sendError to avoid Moqui error screen interference
182 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
183 response.setContentType("application/json")
184 response.setCharacterEncoding("UTF-8")
185 response.writer.write(groovy.json.JsonOutput.toJson([
186 jsonrpc: "2.0",
187 error: [code: -32700, message: "Failed to read request body: " + e.message],
188 id: null
189 ]))
190 return
191 }
192
193 if (!requestBody) {
194 logger.warn("Empty request body in JSON-RPC POST request")
195 // Handle error directly without sendError to avoid Moqui error screen interference
196 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
197 response.setContentType("application/json")
198 response.writer.write(groovy.json.JsonOutput.toJson([
199 jsonrpc: "2.0",
200 error: [code: -32602, message: "Empty request body"],
201 id: null
202 ]))
203 return
204 }
205
206 def rpcRequest
207 try {
208 rpcRequest = jsonSlurper.parseText(requestBody)
209 logger.info("Parsed JSON-RPC request: method=${rpcRequest.method}, id=${rpcRequest.id}")
210 } catch (Exception e) {
211 logger.error("Failed to parse JSON-RPC request: ${e.message}")
212 // Handle error directly without sendError to avoid Moqui error screen interference
213 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
214 response.setContentType("application/json")
215 response.setCharacterEncoding("UTF-8")
216 response.writer.write(groovy.json.JsonOutput.toJson([
217 jsonrpc: "2.0",
218 error: [code: -32700, message: "Invalid JSON: " + e.message],
219 id: null
220 ]))
221 return
222 }
223
224 // Validate JSON-RPC 2.0 basic structure
225 if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
226 logger.warn("Invalid JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}")
227 // Handle error directly without sendError to avoid Moqui error screen interference
228 response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
229 response.setContentType("application/json")
230 response.setCharacterEncoding("UTF-8")
231 response.writer.write(groovy.json.JsonOutput.toJson([
232 jsonrpc: "2.0",
233 error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
234 id: null
235 ]))
236 return
237 }
238
239 // Process MCP method
240 logger.info("Calling processMcpMethod with method: ${rpcRequest.method}, params: ${rpcRequest.params}")
241 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec)
242 logger.info("processMcpMethod returned result: ${result}")
243
244 // Build JSON-RPC response
245 def rpcResponse = [
246 jsonrpc: "2.0",
247 id: rpcRequest.id,
248 result: result
249 ]
250 logger.info("Sending JSON-RPC response: ${rpcResponse}")
251
252 // Send response following official MCP servlet pattern
253 response.setContentType("application/json")
254 response.setCharacterEncoding("UTF-8")
255 response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
256 }
257
258 private void handleSseRequest(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
259 throws IOException {
260
261 logger.info("Handling SSE request from ${request.remoteAddr}")
262
263 // Set SSE headers
264 response.setContentType("text/event-stream")
265 response.setCharacterEncoding("UTF-8")
266 response.setHeader("Cache-Control", "no-cache")
267 response.setHeader("Connection", "keep-alive")
268
269 // Send initial connection event
270 response.writer.write("event: connect\n")
271 response.writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n")
272 response.writer.flush()
273
274 // Keep connection alive with periodic pings
275 long startTime = System.currentTimeMillis()
276 int pingCount = 0
277
278 try {
279 while (!response.isCommitted() && pingCount < 10) { // Limit to 10 pings for testing
280 Thread.sleep(5000) // Wait 5 seconds
281
282 if (!response.isCommitted()) {
283 response.writer.write("event: ping\n")
284 response.writer.write("data: {\"type\":\"ping\",\"count\":${pingCount},\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n")
285 response.writer.flush()
286 pingCount++
287 }
288 }
289 } catch (Exception e) {
290 logger.warn("SSE connection interrupted: ${e.message}")
291 } finally {
292 // Send close event
293 if (!response.isCommitted()) {
294 response.writer.write("event: close\n")
295 response.writer.write("data: {\"type\":\"disconnected\",\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n")
296 response.writer.flush()
297 }
298 }
299 }
300
301 private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec) {
302 logger.info("METHOD: ${method} with params: ${params}")
303 switch (method) {
304 case "initialize":
305 return callMcpService("mcp#Initialize", params, ec)
306 case "ping":
307 return callMcpService("mcp#Ping", params, ec)
308 case "tools/list":
309 return callMcpService("mcp#ToolsList", params, ec)
310 case "tools/call":
311 return callMcpService("mcp#ToolsCall", params, ec)
312 case "resources/list":
313 return callMcpService("mcp#ResourcesList", params, ec)
314 case "resources/read":
315 return callMcpService("mcp#ResourcesRead", params, ec)
316 default:
317 throw new IllegalArgumentException("Unknown MCP method: ${method}")
318 }
319 }
320
321 private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) {
322 logger.info("Calling MCP service: ${serviceName} with params: ${params}")
323
324 try {
325 def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}")
326 .parameters(params ?: [:])
327 .call()
328
329 logger.info("MCP service ${serviceName} result: ${result}")
330 return result.result
331 } catch (Exception e) {
332 logger.error("Error calling MCP service ${serviceName}", e)
333 throw e
334 }
335 }
336
337 private Map<String, Object> initializeMcp(Map params, ExecutionContextImpl ec) {
338 logger.info("MCP Initialize called with params: ${params}")
339
340 // Discover available tools and resources
341 def toolsResult = listTools([:], ec)
342 def resourcesResult = listResources([:], ec)
343
344 def capabilities = [
345 tools: [:],
346 resources: [:],
347 logging: [:]
348 ]
349
350 // Only include tools if we found any
351 if (toolsResult?.tools) {
352 capabilities.tools = [listChanged: true]
353 }
354
355 // Only include resources if we found any
356 if (resourcesResult?.resources) {
357 capabilities.resources = [subscribe: true, listChanged: true]
358 }
359
360 def initResult = [
361 protocolVersion: "2025-06-18",
362 capabilities: capabilities,
363 serverInfo: [
364 name: "Moqui MCP Server",
365 version: "2.0.0"
366 ]
367 ]
368
369 logger.info("MCP Initialize returning: ${initResult}")
370 return initResult
371 }
372
373 private Map<String, Object> pingMcp(Map params, ExecutionContextImpl ec) {
374 logger.info("MCP Ping called with params: ${params}")
375
376 return [
377 result: "pong"
378 ]
379 }
380
381 private Map<String, Object> listTools(Map params, ExecutionContextImpl ec) {
382 // List available Moqui services as tools
383 def tools = []
384
385 // Entity services
386 tools << [
387 name: "EntityFind",
388 description: "Find entities in Moqui",
389 inputSchema: [
390 type: "object",
391 properties: [
392 entity: [type: "string", description: "Entity name"],
393 fields: [type: "array", description: "Fields to select"],
394 constraint: [type: "string", description: "Constraint expression"],
395 limit: [type: "number", description: "Maximum results"]
396 ]
397 ]
398 ]
399
400 tools << [
401 name: "EntityCreate",
402 description: "Create entity records",
403 inputSchema: [
404 type: "object",
405 properties: [
406 entity: [type: "string", description: "Entity name"],
407 fields: [type: "object", description: "Field values"]
408 ]
409 ]
410 ]
411
412 tools << [
413 name: "EntityUpdate",
414 description: "Update entity records",
415 inputSchema: [
416 type: "object",
417 properties: [
418 entity: [type: "string", description: "Entity name"],
419 fields: [type: "object", description: "Field values"],
420 constraint: [type: "string", description: "Constraint expression"]
421 ]
422 ]
423 ]
424
425 tools << [
426 name: "EntityDelete",
427 description: "Delete entity records",
428 inputSchema: [
429 type: "object",
430 properties: [
431 entity: [type: "string", description: "Entity name"],
432 constraint: [type: "string", description: "Constraint expression"]
433 ]
434 ]
435 ]
436
437 // Service execution tools
438 tools << [
439 name: "ServiceCall",
440 description: "Execute Moqui services",
441 inputSchema: [
442 type: "object",
443 properties: [
444 service: [type: "string", description: "Service name (verb:noun)"],
445 parameters: [type: "object", description: "Service parameters"]
446 ]
447 ]
448 ]
449
450 // User management tools
451 tools << [
452 name: "UserFind",
453 description: "Find users in the system",
454 inputSchema: [
455 type: "object",
456 properties: [
457 username: [type: "string", description: "Username filter"],
458 email: [type: "string", description: "Email filter"],
459 enabled: [type: "boolean", description: "Filter by enabled status"]
460 ]
461 ]
462 ]
463
464 // Party management tools
465 tools << [
466 name: "PartyFind",
467 description: "Find parties (organizations, persons)",
468 inputSchema: [
469 type: "object",
470 properties: [
471 partyType: [type: "string", description: "Party type (PERSON, ORGANIZATION)"],
472 partyName: [type: "string", description: "Party name filter"],
473 status: [type: "string", description: "Status filter"]
474 ]
475 ]
476 ]
477
478 // Order management tools
479 tools << [
480 name: "OrderFind",
481 description: "Find sales orders",
482 inputSchema: [
483 type: "object",
484 properties: [
485 orderId: [type: "string", description: "Order ID"],
486 customerId: [type: "string", description: "Customer party ID"],
487 status: [type: "string", description: "Order status"],
488 fromDate: [type: "string", description: "From date (YYYY-MM-DD)"],
489 thruDate: [type: "string", description: "Thru date (YYYY-MM-DD)"]
490 ]
491 ]
492 ]
493
494 // Product management tools
495 tools << [
496 name: "ProductFind",
497 description: "Find products",
498 inputSchema: [
499 type: "object",
500 properties: [
501 productId: [type: "string", description: "Product ID"],
502 productName: [type: "string", description: "Product name filter"],
503 productType: [type: "string", description: "Product type"],
504 category: [type: "string", description: "Product category"]
505 ]
506 ]
507 ]
508
509 // Inventory tools
510 tools << [
511 name: "InventoryCheck",
512 description: "Check product inventory levels",
513 inputSchema: [
514 type: "object",
515 properties: [
516 productId: [type: "string", description: "Product ID"],
517 facilityId: [type: "string", description: "Facility ID"],
518 locationId: [type: "string", description: "Location ID"]
519 ]
520 ]
521 ]
522
523 // System status tools
524 tools << [
525 name: "SystemStatus",
526 description: "Get system status and statistics",
527 inputSchema: [
528 type: "object",
529 properties: [
530 includeMetrics: [type: "boolean", description: "Include performance metrics"],
531 includeCache: [type: "boolean", description: "Include cache statistics"]
532 ]
533 ]
534 ]
535
536 return [tools: tools]
537 }
538
539 private Map<String, Object> callTool(Map params, ExecutionContextImpl ec) {
540 String toolName = params.name as String
541 Map arguments = params.arguments as Map ?: [:]
542
543 logger.info("Calling tool via service: ${toolName} with arguments: ${arguments}")
544
545 try {
546 // Use the existing McpServices.mcp#ToolsCall service
547 def result = ec.service.sync().name("org.moqui.mcp.McpServices.mcp#ToolsCall")
548 .parameters([name: toolName, arguments: arguments])
549 .call()
550
551 logger.info("Tool call result: ${result}")
552 return result.result
553 } catch (Exception e) {
554 logger.error("Error calling tool ${toolName} via service", e)
555 return [
556 content: [[type: "text", text: "Error: " + e.message]],
557 isError: true
558 ]
559 }
560 }
561
562 private Map<String, Object> callEntityFind(Map arguments, ExecutionContextImpl ec) {
563 String entity = arguments.entity as String
564 List<String> fields = arguments.fields as List<String>
565 String constraint = arguments.constraint as String
566 Integer limit = arguments.limit as Integer
567
568 def finder = ec.entity.find(entity).selectFields(fields ?: ["*"]).limit(limit ?: 100)
569 if (constraint) {
570 finder.condition(constraint)
571 }
572 def result = finder.list()
573
574 return [
575 content: [[type: "text", text: "Found ${result.size()} records: ${result}"]],
576 isError: false
577 ]
578 }
579
580 private Map<String, Object> callEntityCreate(Map arguments, ExecutionContextImpl ec) {
581 String entity = arguments.entity as String
582 Map fields = arguments.fields as Map
583
584 def result = ec.entity.create(entity).setAll(fields).create()
585
586 return [
587 content: [[type: "text", text: "Created record: ${result}"]],
588 isError: false
589 ]
590 }
591
592 private Map<String, Object> callEntityUpdate(Map arguments, ExecutionContextImpl ec) {
593 String entity = arguments.entity as String
594 Map fields = arguments.fields as Map
595 String constraint = arguments.constraint as String
596
597 def updater = ec.entity.update(entity).setAll(fields)
598 if (constraint) {
599 updater.condition(constraint)
600 }
601 int updated = updater.update()
602
603 return [
604 content: [[type: "text", text: "Updated ${updated} records"]],
605 isError: false
606 ]
607 }
608
609 private Map<String, Object> callEntityDelete(Map arguments, ExecutionContextImpl ec) {
610 String entity = arguments.entity as String
611 String constraint = arguments.constraint as String
612
613 def deleter = ec.entity.delete(entity)
614 if (constraint) {
615 deleter.condition(constraint)
616 }
617 int deleted = deleter.delete()
618
619 return [
620 content: [[type: "text", text: "Deleted ${deleted} records"]],
621 isError: false
622 ]
623 }
624
625 private Map<String, Object> callService(Map arguments, ExecutionContextImpl ec) {
626 String serviceName = arguments.service as String
627 Map parameters = arguments.parameters as Map ?: [:]
628
629 try {
630 def result = ec.service.sync().name(serviceName).parameters(parameters).call()
631 return [
632 content: [[type: "text", text: "Service ${serviceName} executed successfully. Result: ${result}"]],
633 isError: false
634 ]
635 } catch (Exception e) {
636 return [
637 content: [[type: "text", text: "Error executing service ${serviceName}: ${e.message}"]],
638 isError: true
639 ]
640 }
641 }
642
643 private Map<String, Object> callUserFind(Map arguments, ExecutionContextImpl ec) {
644 String username = arguments.username as String
645 String email = arguments.email as String
646 Boolean enabled = arguments.enabled as Boolean
647
648 def condition = new StringBuilder("1=1")
649 def parameters = [:]
650
651 if (username) {
652 condition.append(" AND username = :username")
653 parameters.username = username
654 }
655 if (email) {
656 condition.append(" AND email_address = :email")
657 parameters.email = email
658 }
659 if (enabled != null) {
660 condition.append(" AND enabled = :enabled")
661 parameters.enabled = enabled ? "Y" : "N"
662 }
663
664 def result = ec.entity.find("moqui.security.UserAccount")
665 .condition(condition.toString(), parameters)
666 .limit(50)
667 .list()
668
669 return [
670 content: [[type: "text", text: "Found ${result.size()} users: ${result.collect { [username: it.username, email: it.emailAddress, enabled: it.enabled] }}"]],
671 isError: false
672 ]
673 }
674
675 private Map<String, Object> callPartyFind(Map arguments, ExecutionContextImpl ec) {
676 String partyType = arguments.partyType as String
677 String partyName = arguments.partyName as String
678 String status = arguments.status as String
679
680 def condition = new StringBuilder("1=1")
681 def parameters = [:]
682
683 if (partyType) {
684 condition.append(" AND party_type_id = :partyType")
685 parameters.partyType = partyType
686 }
687 if (partyName) {
688 condition.append(" AND (party_name ILIKE :partyName OR party_name ILIKE :partyName)")
689 parameters.partyName = "%${partyName}%"
690 }
691 if (status) {
692 condition.append(" AND status_id = :status")
693 parameters.status = status
694 }
695
696 def result = ec.entity.find("mantle.party.PartyAndName")
697 .condition(condition.toString(), parameters)
698 .limit(50)
699 .list()
700
701 return [
702 content: [[type: "text", text: "Found ${result.size()} parties: ${result.collect { [partyId: it.partyId, type: it.partyTypeId, name: it.partyName, status: it.statusId] }}"]],
703 isError: false
704 ]
705 }
706
707 private Map<String, Object> callOrderFind(Map arguments, ExecutionContextImpl ec) {
708 String orderId = arguments.orderId as String
709 String customerId = arguments.customerId as String
710 String status = arguments.status as String
711 String fromDate = arguments.fromDate as String
712 String thruDate = arguments.thruDate as String
713
714 def condition = new StringBuilder("1=1")
715 def parameters = [:]
716
717 if (orderId) {
718 condition.append(" AND order_id = :orderId")
719 parameters.orderId = orderId
720 }
721 if (customerId) {
722 condition.append(" AND customer_party_id = :customerId")
723 parameters.customerId = customerId
724 }
725 if (status) {
726 condition.append(" AND status_id = :status")
727 parameters.status = status
728 }
729 if (fromDate) {
730 condition.append(" AND order_date >= :fromDate")
731 parameters.fromDate = fromDate
732 }
733 if (thruDate) {
734 condition.append(" AND order_date <= :thruDate")
735 parameters.thruDate = thruDate
736 }
737
738 def result = ec.entity.find("mantle.order.OrderHeader")
739 .condition(condition.toString(), parameters)
740 .limit(50)
741 .list()
742
743 return [
744 content: [[type: "text", text: "Found ${result.size()} orders: ${result.collect { [orderId: it.orderId, customer: it.customerPartyId, status: it.statusId, date: it.orderDate, total: it.grandTotal] }}"]],
745 isError: false
746 ]
747 }
748
749 private Map<String, Object> callProductFind(Map arguments, ExecutionContextImpl ec) {
750 String productId = arguments.productId as String
751 String productName = arguments.productName as String
752 String productType = arguments.productType as String
753 String category = arguments.category as String
754
755 def condition = new StringBuilder("1=1")
756 def parameters = [:]
757
758 if (productId) {
759 condition.append(" AND product_id = :productId")
760 parameters.productId = productId
761 }
762 if (productName) {
763 condition.append(" AND (product_name ILIKE :productName OR internal_name ILIKE :productName)")
764 parameters.productName = "%${productName}%"
765 }
766 if (productType) {
767 condition.append(" AND product_type_id = :productType")
768 parameters.productType = productType
769 }
770 if (category) {
771 condition.append(" AND primary_product_category_id = :category")
772 parameters.category = category
773 }
774
775 def result = ec.entity.find("mantle.product.Product")
776 .condition(condition.toString(), parameters)
777 .limit(50)
778 .list()
779
780 return [
781 content: [[type: "text", text: "Found ${result.size()} products: ${result.collect { [productId: it.productId, name: it.productName, type: it.productTypeId, category: it.primaryProductCategoryId] }}"]],
782 isError: false
783 ]
784 }
785
786 private Map<String, Object> callInventoryCheck(Map arguments, ExecutionContextImpl ec) {
787 String productId = arguments.productId as String
788 String facilityId = arguments.facilityId as String
789 String locationId = arguments.locationId as String
790
791 if (!productId) {
792 return [
793 content: [[type: "text", text: "Error: productId is required"]],
794 isError: true
795 ]
796 }
797
798 def condition = new StringBuilder("product_id = :productId")
799 def parameters = [productId: productId]
800
801 if (facilityId) {
802 condition.append(" AND facility_id = :facilityId")
803 parameters.facilityId = facilityId
804 }
805 if (locationId) {
806 condition.append(" AND location_id = :locationId")
807 parameters.locationId = locationId
808 }
809
810 def result = ec.entity.find("mantle.product.inventory.InventoryItem")
811 .condition(condition.toString(), parameters)
812 .list()
813
814 def totalAvailable = result.sum { it.availableToPromiseTotal ?: 0 }
815 def totalOnHand = result.sum { it.quantityOnHandTotal ?: 0 }
816
817 return [
818 content: [[type: "text", text: "Inventory for ${productId}: Available: ${totalAvailable}, On Hand: ${totalOnHand}, Facilities: ${result.collect { [facility: it.facilityId, location: it.locationId, available: it.availableToPromiseTotal, onHand: it.quantityOnHandTotal] }}"]],
819 isError: false
820 ]
821 }
822
823 private Map<String, Object> callSystemStatus(Map arguments, ExecutionContextImpl ec) {
824 Boolean includeMetrics = arguments.includeMetrics as Boolean ?: false
825 Boolean includeCache = arguments.includeCache as Boolean ?: false
826
827 def status = [
828 serverTime: new Date(),
829 frameworkVersion: "3.1.0-rc2",
830 userCount: ec.entity.find("moqui.security.UserAccount").count(),
831 partyCount: ec.entity.find("mantle.party.Party").count(),
832 productCount: ec.entity.find("mantle.product.Product").count(),
833 orderCount: ec.entity.find("mantle.order.OrderHeader").count()
834 ]
835
836 if (includeMetrics) {
837 status.memory = [
838 total: Runtime.getRuntime().totalMemory(),
839 free: Runtime.getRuntime().freeMemory(),
840 max: Runtime.getRuntime().maxMemory()
841 ]
842 }
843
844 if (includeCache) {
845 def cacheFacade = ec.getCache()
846 status.cache = [
847 cacheNames: cacheFacade.getCacheNames(),
848 // Note: More detailed cache stats would require cache-specific API calls
849 ]
850 }
851
852 return [
853 content: [[type: "text", text: "System Status: ${status}"]],
854 isError: false
855 ]
856 }
857
858 private Map<String, Object> listResources(Map params, ExecutionContextImpl ec) {
859 // List available entities as resources
860 def resources = []
861
862 // Get all entity names
863 def entityNames = ec.entity.getEntityNames()
864 for (String entityName : entityNames) {
865 resources << [
866 uri: "entity://${entityName}",
867 name: entityName,
868 description: "Moqui Entity: ${entityName}",
869 mimeType: "application/json"
870 ]
871 }
872
873 return [resources: resources]
874 }
875
876 private Map<String, Object> readResource(Map params, ExecutionContextImpl ec) {
877 String uri = params.uri as String
878
879 if (uri.startsWith("entity://")) {
880 String entityName = uri.substring(9) // Remove "entity://" prefix
881
882 try {
883 // Get entity definition
884 def entityDef = ec.entity.getEntityDefinition(entityName)
885 if (!entityDef) {
886 throw new IllegalArgumentException("Entity not found: ${entityName}")
887 }
888
889 // Get basic entity info
890 def entityInfo = [
891 name: entityName,
892 tableName: entityDef.tableName,
893 fields: entityDef.allFieldInfo.collect { [name: it.name, type: it.type] }
894 ]
895
896 return [
897 contents: [[
898 uri: uri,
899 mimeType: "application/json",
900 text: groovy.json.JsonOutput.toJson(entityInfo)
901 ]]
902 ]
903 } catch (Exception e) {
904 throw new IllegalArgumentException("Error reading entity ${entityName}: " + e.message)
905 }
906 } else {
907 throw new IllegalArgumentException("Unsupported resource URI: ${uri}")
908 }
909 }
910
911 // CORS handling based on MoquiServlet pattern
912 private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) {
913 String originHeader = request.getHeader("Origin")
914 if (originHeader) {
915 response.setHeader("Access-Control-Allow-Origin", originHeader)
916 response.setHeader("Access-Control-Allow-Credentials", "true")
917 }
918
919 String methodHeader = request.getHeader("Access-Control-Request-Method")
920 if (methodHeader) {
921 response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
922 response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
923 response.setHeader("Access-Control-Max-Age", "3600")
924 return true
925 }
926 return false
927 }
928 }
...\ No newline at end of file ...\ No newline at end of file
1 /*
2 * This software is in the public domain under CC0 1.0 Universal plus a
3 * Grant of Patent License.
4 *
5 * To the extent possible under law, author(s) have dedicated all
6 * copyright and related and neighboring rights to this software to the
7 * public domain worldwide. This software is distributed without any
8 * warranty.
9 *
10 * You should have received a copy of the CC0 Public Domain Dedication
11 * along with this software (see the LICENSE.md file). If not, see
12 * <http://creativecommons.org/publicdomain/zero/1.0/>.
13 */
14 package org.moqui.mcp
15
16 import groovy.json.JsonSlurper
17 import org.moqui.impl.context.ExecutionContextFactoryImpl
18 import org.moqui.context.ArtifactAuthorizationException
19 import org.moqui.context.ArtifactTarpitException
20 import org.moqui.impl.context.ExecutionContextImpl
21 import org.slf4j.Logger
22 import org.slf4j.LoggerFactory
23
24 import javax.servlet.AsyncContext
25 import javax.servlet.AsyncContextListener
26 import javax.servlet.AsyncEvent
27 import javax.servlet.ServletConfig
28 import javax.servlet.ServletException
29 import javax.servlet.http.HttpServlet
30 import javax.servlet.http.HttpServletRequest
31 import javax.servlet.http.HttpServletResponse
32 import java.util.concurrent.ConcurrentHashMap
33 import java.util.concurrent.Executors
34 import java.util.concurrent.ScheduledExecutorService
35 import java.util.concurrent.TimeUnit
36
37 /**
38 * Service-Based MCP Servlet that delegates all business logic to McpServices.xml.
39 *
40 * This servlet improves upon the original MoquiMcpServlet by:
41 * - Properly delegating to existing McpServices.xml instead of reimplementing logic
42 * - Adding SSE support for real-time bidirectional communication
43 * - Providing better session management and error handling
44 * - Supporting async operations for scalability
45 */
46 class ServiceBasedMcpServlet extends HttpServlet {
47 protected final static Logger logger = LoggerFactory.getLogger(ServiceBasedMcpServlet.class)
48
49 private JsonSlurper jsonSlurper = new JsonSlurper()
50
51 // Session management for SSE connections
52 private final Map<String, AsyncContext> sseConnections = new ConcurrentHashMap<>()
53 private final Map<String, String> sessionClients = new ConcurrentHashMap<>()
54
55 // Executor for async operations and keep-alive pings
56 private ScheduledExecutorService executorService
57
58 // Configuration
59 private String sseEndpoint = "/sse"
60 private String messageEndpoint = "/mcp/message"
61 private int keepAliveIntervalSeconds = 30
62 private int maxConnections = 100
63
64 @Override
65 void init(ServletConfig config) throws ServletException {
66 super.init(config)
67
68 // Read configuration from servlet init parameters
69 sseEndpoint = config.getInitParameter("sseEndpoint") ?: sseEndpoint
70 messageEndpoint = config.getInitParameter("messageEndpoint") ?: messageEndpoint
71 keepAliveIntervalSeconds = config.getInitParameter("keepAliveIntervalSeconds")?.toInteger() ?: keepAliveIntervalSeconds
72 maxConnections = config.getInitParameter("maxConnections")?.toInteger() ?: maxConnections
73
74 // Initialize executor service
75 executorService = Executors.newScheduledThreadPool(4)
76
77 // Start keep-alive task
78 startKeepAliveTask()
79
80 String webappName = config.getInitParameter("moqui-name") ?:
81 config.getServletContext().getInitParameter("moqui-name")
82
83 logger.info("ServiceBasedMcpServlet initialized for webapp ${webappName}")
84 logger.info("SSE endpoint: ${sseEndpoint}, Message endpoint: ${messageEndpoint}")
85 logger.info("Keep-alive interval: ${keepAliveIntervalSeconds}s, Max connections: ${maxConnections}")
86 logger.info("All business logic delegated to McpServices.xml")
87 }
88
89 @Override
90 void destroy() {
91 super.destroy()
92
93 // Shutdown executor service
94 if (executorService) {
95 executorService.shutdown()
96 try {
97 if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
98 executorService.shutdownNow()
99 }
100 } catch (InterruptedException e) {
101 executorService.shutdownNow()
102 Thread.currentThread().interrupt()
103 }
104 }
105
106 // Close all SSE connections
107 sseConnections.values().each { asyncContext ->
108 try {
109 asyncContext.complete()
110 } catch (Exception e) {
111 logger.warn("Error closing SSE connection: ${e.message}")
112 }
113 }
114 sseConnections.clear()
115 sessionClients.clear()
116
117 logger.info("ServiceBasedMcpServlet destroyed")
118 }
119
120 @Override
121 void service(HttpServletRequest request, HttpServletResponse response)
122 throws ServletException, IOException {
123
124 ExecutionContextFactoryImpl ecfi =
125 (ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory")
126 String webappName = getInitParameter("moqui-name") ?:
127 getServletContext().getInitParameter("moqui-name")
128
129 if (ecfi == null || webappName == null) {
130 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
131 "System is initializing, try again soon.")
132 return
133 }
134
135 // Handle CORS
136 if (handleCors(request, response, webappName, ecfi)) return
137
138 String pathInfo = request.getPathInfo()
139
140 // Route based on endpoint
141 if (pathInfo?.startsWith(sseEndpoint)) {
142 handleSseConnection(request, response, ecfi, webappName)
143 } else if (pathInfo?.startsWith(messageEndpoint)) {
144 handleMessage(request, response, ecfi, webappName)
145 } else {
146 // Legacy support for /rpc endpoint
147 if (pathInfo?.startsWith("/rpc")) {
148 handleLegacyRpc(request, response, ecfi, webappName)
149 } else {
150 response.sendError(HttpServletResponse.SC_NOT_FOUND, "MCP endpoint not found")
151 }
152 }
153 }
154
155 private void handleSseConnection(HttpServletRequest request, HttpServletResponse response,
156 ExecutionContextFactoryImpl ecfi, String webappName)
157 throws IOException {
158
159 logger.info("New SSE connection request from ${request.remoteAddr}")
160
161 // Check connection limit
162 if (sseConnections.size() >= maxConnections) {
163 response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE,
164 "Too many SSE connections")
165 return
166 }
167
168 // Set SSE headers
169 response.setContentType("text/event-stream")
170 response.setCharacterEncoding("UTF-8")
171 response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")
172 response.setHeader("Pragma", "no-cache")
173 response.setHeader("Expires", "0")
174 response.setHeader("Connection", "keep-alive")
175
176 // Generate session ID
177 String sessionId = generateSessionId()
178
179 // Store client info
180 String userAgent = request.getHeader("User-Agent") ?: "Unknown"
181 sessionClients.put(sessionId, userAgent)
182
183 // Enable async support
184 AsyncContext asyncContext = request.startAsync(request, response)
185 asyncContext.setTimeout(0) // No timeout
186 sseConnections.put(sessionId, asyncContext)
187
188 logger.info("SSE connection established: ${sessionId} from ${userAgent}")
189
190 // Send initial connection event
191 sendSseEvent(sessionId, "connect", [
192 type: "connected",
193 sessionId: sessionId,
194 timestamp: System.currentTimeMillis(),
195 serverInfo: [
196 name: "Moqui Service-Based MCP Server",
197 version: "2.1.0",
198 protocolVersion: "2025-06-18",
199 endpoints: [
200 sse: sseEndpoint,
201 message: messageEndpoint
202 ],
203 architecture: "Service-based - all business logic delegated to McpServices.xml"
204 ]
205 ])
206
207 // Set up connection close handling
208 asyncContext.addListener(new AsyncContextListener() {
209 @Override
210 void onComplete(AsyncEvent event) throws IOException {
211 sseConnections.remove(sessionId)
212 sessionClients.remove(sessionId)
213 logger.info("SSE connection completed: ${sessionId}")
214 }
215
216 @Override
217 void onTimeout(AsyncEvent event) throws IOException {
218 sseConnections.remove(sessionId)
219 sessionClients.remove(sessionId)
220 logger.info("SSE connection timeout: ${sessionId}")
221 }
222
223 @Override
224 void onError(AsyncEvent event) throws IOException {
225 sseConnections.remove(sessionId)
226 sessionClients.remove(sessionId)
227 logger.warn("SSE connection error: ${sessionId} - ${event.throwable?.message}")
228 }
229
230 @Override
231 void onStartAsync(AsyncEvent event) throws IOException {
232 // No action needed
233 }
234 })
235 }
236
237 private void handleMessage(HttpServletRequest request, HttpServletResponse response,
238 ExecutionContextFactoryImpl ecfi, String webappName)
239 throws IOException {
240
241 long startTime = System.currentTimeMillis()
242
243 if (logger.traceEnabled) {
244 logger.trace("Start MCP message request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]")
245 }
246
247 ExecutionContextImpl activeEc = ecfi.activeContext.get()
248 if (activeEc != null) {
249 logger.warn("In ServiceBasedMcpServlet.handleMessage there is already an ExecutionContext for user ${activeEc.user.username}")
250 activeEc.destroy()
251 }
252
253 ExecutionContextImpl ec = ecfi.getEci()
254
255 try {
256 // Initialize web facade for authentication
257 ec.initWebFacade(webappName, request, response)
258
259 logger.info("Service-Based MCP Message authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}")
260
261 // If no user authenticated, try to authenticate as admin for MCP requests
262 if (!ec.user?.userId) {
263 logger.info("No user authenticated, attempting admin login for Service-Based MCP")
264 try {
265 ec.user.loginUser("admin", "admin")
266 logger.info("Service-Based MCP Admin login successful, user: ${ec.user?.username}")
267 } catch (Exception e) {
268 logger.warn("Service-Based MCP Admin login failed: ${e.message}")
269 }
270 }
271
272 // Handle different HTTP methods
273 String method = request.getMethod()
274
275 if ("GET".equals(method)) {
276 // Handle SSE subscription or status check
277 handleGetMessage(request, response, ec)
278 } else if ("POST".equals(method)) {
279 // Handle JSON-RPC message
280 handlePostMessage(request, response, ec)
281 } else {
282 response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED,
283 "Method not allowed. Use GET for SSE subscription or POST for JSON-RPC messages.")
284 }
285
286 } catch (ArtifactAuthorizationException e) {
287 logger.warn("Service-Based MCP Access Forbidden (no authz): " + e.message)
288 sendJsonRpcError(response, -32001, "Access Forbidden: " + e.message, null)
289 } catch (ArtifactTarpitException e) {
290 logger.warn("Service-Based MCP Too Many Requests (tarpit): " + e.message)
291 response.setStatus(429)
292 if (e.getRetryAfterSeconds()) {
293 response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
294 }
295 sendJsonRpcError(response, -32002, "Too Many Requests: " + e.message, null)
296 } catch (Throwable t) {
297 logger.error("Error in Service-Based MCP message request", t)
298 sendJsonRpcError(response, -32603, "Internal error: " + t.message, null)
299 } finally {
300 ec.destroy()
301 }
302 }
303
304 private void handleGetMessage(HttpServletRequest request, HttpServletResponse response,
305 ExecutionContextImpl ec) throws IOException {
306
307 String sessionId = request.getParameter("sessionId")
308 String acceptHeader = request.getHeader("Accept")
309
310 // If client wants SSE and has sessionId, this is a subscription request
311 if (acceptHeader?.contains("text/event-stream") && sessionId) {
312 if (sseConnections.containsKey(sessionId)) {
313 response.setContentType("text/event-stream")
314 response.setCharacterEncoding("UTF-8")
315 response.setHeader("Cache-Control", "no-cache")
316 response.setHeader("Connection", "keep-alive")
317
318 // Send subscription confirmation
319 response.writer.write("event: subscribed\n")
320 response.writer.write("data: {\"type\":\"subscribed\",\"sessionId\":\"${sessionId}\",\"timestamp\":\"${System.currentTimeMillis()}\",\"architecture\":\"Service-based\"}\n\n")
321 response.writer.flush()
322 } else {
323 response.sendError(HttpServletResponse.SC_NOT_FOUND, "Session not found")
324 }
325 } else {
326 // Return server status
327 response.setContentType("application/json")
328 response.setCharacterEncoding("UTF-8")
329
330 def status = [
331 serverInfo: [
332 name: "Moqui Service-Based MCP Server",
333 version: "2.1.0",
334 protocolVersion: "2025-06-18",
335 architecture: "Service-based - all business logic delegated to McpServices.xml"
336 ],
337 connections: [
338 active: sseConnections.size(),
339 max: maxConnections
340 ],
341 endpoints: [
342 sse: sseEndpoint,
343 message: messageEndpoint,
344 rpc: "/rpc"
345 ],
346 capabilities: [
347 tools: true,
348 resources: true,
349 prompts: true,
350 sse: true,
351 jsonRpc: true,
352 services: "McpServices.xml"
353 ]
354 ]
355
356 response.writer.write(groovy.json.JsonOutput.toJson(status))
357 }
358 }
359
360 private void handlePostMessage(HttpServletRequest request, HttpServletResponse response,
361 ExecutionContextImpl ec) throws IOException {
362
363 // Read and parse JSON-RPC request
364 String requestBody
365 try {
366 BufferedReader reader = request.reader
367 StringBuilder body = new StringBuilder()
368 String line
369 while ((line = reader.readLine()) != null) {
370 body.append(line)
371 }
372 requestBody = body.toString()
373
374 } catch (IOException e) {
375 logger.error("Failed to read request body: ${e.message}")
376 sendJsonRpcError(response, -32700, "Failed to read request body: " + e.message, null)
377 return
378 }
379
380 if (!requestBody) {
381 logger.warn("Empty request body in JSON-RPC POST request")
382 sendJsonRpcError(response, -32602, "Empty request body", null)
383 return
384 }
385
386 def rpcRequest
387 try {
388 rpcRequest = jsonSlurper.parseText(requestBody)
389 } catch (Exception e) {
390 logger.error("Failed to parse JSON-RPC request: ${e.message}")
391 sendJsonRpcError(response, -32700, "Invalid JSON: " + e.message, null)
392 return
393 }
394
395 // Validate JSON-RPC 2.0 basic structure
396 if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
397 logger.warn("Invalid JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}")
398 sendJsonRpcError(response, -32600, "Invalid JSON-RPC 2.0 request", rpcRequest?.id)
399 return
400 }
401
402 // Process MCP method by delegating to services
403 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, rpcRequest)
404
405 // Build JSON-RPC response
406 def rpcResponse = [
407 jsonrpc: "2.0",
408 id: rpcRequest.id,
409 result: result
410 ]
411
412 // Send response
413 response.setContentType("application/json")
414 response.setCharacterEncoding("UTF-8")
415 response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
416 }
417
418 private void handleLegacyRpc(HttpServletRequest request, HttpServletResponse response,
419 ExecutionContextFactoryImpl ecfi, String webappName)
420 throws IOException {
421
422 // Legacy support - delegate to existing MoquiMcpServlet logic
423 logger.info("Handling legacy RPC request - redirecting to services")
424
425 // For legacy requests, we can use the same service-based approach
426 ExecutionContextImpl activeEc = ecfi.activeContext.get()
427 if (activeEc != null) {
428 logger.warn("In ServiceBasedMcpServlet.handleLegacyRpc there is already an ExecutionContext for user ${activeEc.user.username}")
429 activeEc.destroy()
430 }
431
432 ExecutionContextImpl ec = ecfi.getEci()
433
434 try {
435 // Initialize web facade for authentication
436 ec.initWebFacade(webappName, request, response)
437
438 // If no user authenticated, try to authenticate as admin for MCP requests
439 if (!ec.user?.userId) {
440 logger.info("No user authenticated, attempting admin login for Legacy MCP")
441 try {
442 ec.user.loginUser("admin", "admin")
443 logger.info("Legacy MCP Admin login successful, user: ${ec.user?.username}")
444 } catch (Exception e) {
445 logger.warn("Legacy MCP Admin login failed: ${e.message}")
446 }
447 }
448
449 // Read and parse JSON-RPC request (same as POST handling)
450 String requestBody
451 try {
452 BufferedReader reader = request.reader
453 StringBuilder body = new StringBuilder()
454 String line
455 while ((line = reader.readLine()) != null) {
456 body.append(line)
457 }
458 requestBody = body.toString()
459
460 } catch (IOException e) {
461 logger.error("Failed to read legacy RPC request body: ${e.message}")
462 sendJsonRpcError(response, -32700, "Failed to read request body: " + e.message, null)
463 return
464 }
465
466 if (!requestBody) {
467 logger.warn("Empty request body in legacy RPC POST request")
468 sendJsonRpcError(response, -32602, "Empty request body", null)
469 return
470 }
471
472 def rpcRequest
473 try {
474 rpcRequest = jsonSlurper.parseText(requestBody)
475 } catch (Exception e) {
476 logger.error("Failed to parse legacy JSON-RPC request: ${e.message}")
477 sendJsonRpcError(response, -32700, "Invalid JSON: " + e.message, null)
478 return
479 }
480
481 // Validate JSON-RPC 2.0 basic structure
482 if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
483 logger.warn("Invalid legacy JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}")
484 sendJsonRpcError(response, -32600, "Invalid JSON-RPC 2.0 request", rpcRequest?.id)
485 return
486 }
487
488 // Process MCP method by delegating to services
489 def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, rpcRequest)
490
491 // Build JSON-RPC response
492 def rpcResponse = [
493 jsonrpc: "2.0",
494 id: rpcRequest.id,
495 result: result
496 ]
497
498 // Send response
499 response.setContentType("application/json")
500 response.setCharacterEncoding("UTF-8")
501 response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
502
503 } catch (ArtifactAuthorizationException e) {
504 logger.warn("Legacy MCP Access Forbidden (no authz): " + e.message)
505 sendJsonRpcError(response, -32001, "Access Forbidden: " + e.message, null)
506 } catch (ArtifactTarpitException e) {
507 logger.warn("Legacy MCP Too Many Requests (tarpit): " + e.message)
508 response.setStatus(429)
509 if (e.getRetryAfterSeconds()) {
510 response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
511 }
512 sendJsonRpcError(response, -32002, "Too Many Requests: " + e.message, null)
513 } catch (Throwable t) {
514 logger.error("Error in legacy MCP message request", t)
515 sendJsonRpcError(response, -32603, "Internal error: " + t.message, null)
516 } finally {
517 ec.destroy()
518 }
519 }
520
521 private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, def rpcRequest) {
522 logger.info("Service-Based METHOD: ${method} with params: ${params}")
523
524 try {
525 switch (method) {
526 case "initialize":
527 return callMcpService("mcp#Initialize", params, ec)
528 case "ping":
529 return callMcpService("mcp#Ping", params, ec)
530 case "tools/list":
531 return callMcpService("mcp#ToolsList", params, ec)
532 case "tools/call":
533 return callMcpService("mcp#ToolsCall", params, ec)
534 case "resources/list":
535 return callMcpService("mcp#ResourcesList", params, ec)
536 case "resources/read":
537 return callMcpService("mcp#ResourcesRead", params, ec)
538 case "notifications/subscribe":
539 return handleSubscription(params, ec, rpcRequest)
540 default:
541 throw new IllegalArgumentException("Unknown MCP method: ${method}")
542 }
543 } catch (Exception e) {
544 logger.error("Error processing Service-Based MCP method ${method}", e)
545 throw e
546 }
547 }
548
549 private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) {
550 logger.info("Service-Based Calling MCP service: ${serviceName} with params: ${params}")
551
552 try {
553 def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}")
554 .parameters(params ?: [:])
555 .call()
556
557 logger.info("Service-Based MCP service ${serviceName} result: ${result}")
558 return result.result
559 } catch (Exception e) {
560 logger.error("Error calling Service-Based MCP service ${serviceName}", e)
561 throw e
562 }
563 }
564
565 private Map<String, Object> handleSubscription(Map params, ExecutionContextImpl ec, def rpcRequest) {
566 String sessionId = params.sessionId as String
567 String eventType = params.eventType as String
568
569 logger.info("Service-Based Subscription request: sessionId=${sessionId}, eventType=${eventType}")
570
571 if (!sessionId || !sseConnections.containsKey(sessionId)) {
572 throw new IllegalArgumentException("Invalid or expired session")
573 }
574
575 // Store subscription (in a real implementation, you'd maintain subscription lists)
576 // For now, just confirm subscription
577
578 // Send subscription confirmation via SSE
579 sendSseEvent(sessionId, "subscribed", [
580 type: "subscription_confirmed",
581 sessionId: sessionId,
582 eventType: eventType,
583 timestamp: System.currentTimeMillis(),
584 architecture: "Service-based via McpServices.xml"
585 ])
586
587 return [
588 subscribed: true,
589 sessionId: sessionId,
590 eventType: eventType,
591 timestamp: System.currentTimeMillis()
592 ]
593 }
594
595 private void sendJsonRpcError(HttpServletResponse response, int code, String message, Object id) throws IOException {
596 response.setStatus(HttpServletResponse.SC_OK)
597 response.setContentType("application/json")
598 response.setCharacterEncoding("UTF-8")
599
600 def errorResponse = [
601 jsonrpc: "2.0",
602 error: [code: code, message: message],
603 id: id
604 ]
605
606 response.writer.write(groovy.json.JsonOutput.toJson(errorResponse))
607 }
608
609 private void sendSseEvent(String sessionId, String eventType, Map data) {
610 AsyncContext asyncContext = sseConnections.get(sessionId)
611 if (!asyncContext) {
612 logger.debug("SSE connection not found for session: ${sessionId}")
613 return
614 }
615
616 try {
617 HttpServletResponse response = asyncContext.getResponse()
618 response.writer.write("event: ${eventType}\n")
619 response.writer.write("data: ${groovy.json.JsonOutput.toJson(data)}\n\n")
620 response.writer.flush()
621 } catch (Exception e) {
622 logger.warn("Failed to send SSE event to ${sessionId}: ${e.message}")
623 // Remove broken connection
624 sseConnections.remove(sessionId)
625 sessionClients.remove(sessionId)
626 }
627 }
628
629 private void broadcastSseEvent(String eventType, Map data) {
630 sseConnections.keySet().each { sessionId ->
631 sendSseEvent(sessionId, eventType, data)
632 }
633 }
634
635 private void startKeepAliveTask() {
636 executorService.scheduleWithFixedDelay({
637 try {
638 sseConnections.keySet().each { sessionId ->
639 sendSseEvent(sessionId, "ping", [
640 type: "ping",
641 timestamp: System.currentTimeMillis(),
642 connections: sseConnections.size(),
643 architecture: "Service-based via McpServices.xml"
644 ])
645 }
646 } catch (Exception e) {
647 logger.warn("Error in Service-Based keep-alive task: ${e.message}")
648 }
649 }, keepAliveIntervalSeconds, keepAliveIntervalSeconds, TimeUnit.SECONDS)
650 }
651
652 private String generateSessionId() {
653 return UUID.randomUUID().toString()
654 }
655
656 // CORS handling based on MoquiServlet pattern
657 private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) {
658 String originHeader = request.getHeader("Origin")
659 if (originHeader) {
660 response.setHeader("Access-Control-Allow-Origin", originHeader)
661 response.setHeader("Access-Control-Allow-Credentials", "true")
662 }
663
664 String methodHeader = request.getHeader("Access-Control-Request-Method")
665 if (methodHeader) {
666 response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
667 response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
668 response.setHeader("Access-Control-Max-Age", "3600")
669 return true
670 }
671 return false
672 }
673 }
...\ No newline at end of file ...\ No newline at end of file
1 <?xml version="1.0" encoding="UTF-8"?>
2 <!--
3 This software is in the public domain under CC0 1.0 Universal plus a
4 Grant of Patent License.
5
6 To the extent possible under law, author(s) have dedicated all
7 copyright and related and neighboring rights to this software to the
8 public domain worldwide. This software is distributed without any
9 warranty.
10
11 You should have received a copy of the CC0 Public Domain Dedication
12 along with this software (see the LICENSE.md file). If not, see
13 <http://creativecommons.org/publicdomain/zero/1.0/>.
14 -->
15
16 <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
17 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
18 xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
19 http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
20 version="4.0">
21
22 <!-- Service-Based MCP Servlet Configuration -->
23 <servlet>
24 <servlet-name>ServiceBasedMcpServlet</servlet-name>
25 <servlet-class>org.moqui.mcp.ServiceBasedMcpServlet</servlet-class>
26
27 <!-- Configuration Parameters -->
28 <init-param>
29 <param-name>sseEndpoint</param-name>
30 <param-value>/sse</param-value>
31 </init-param>
32 <init-param>
33 <param-name>messageEndpoint</param-name>
34 <param-value>/mcp/message</param-value>
35 </init-param>
36 <init-param>
37 <param-name>keepAliveIntervalSeconds</param-name>
38 <param-value>30</param-value>
39 </init-param>
40 <init-param>
41 <param-name>maxConnections</param-name>
42 <param-value>100</param-value>
43 </init-param>
44
45 <!-- Enable async support for SSE -->
46 <async-supported>true</async-supported>
47
48 <!-- Load on startup -->
49 <load-on-startup>5</load-on-startup>
50 </servlet>
51
52 <!-- Servlet Mappings -->
53 <servlet-mapping>
54 <servlet-name>ServiceBasedMcpServlet</servlet-name>
55 <url-pattern>/sse/*</url-pattern>
56 </servlet-mapping>
57
58 <servlet-mapping>
59 <servlet-name>ServiceBasedMcpServlet</servlet-name>
60 <url-pattern>/mcp/message/*</url-pattern>
61 </servlet-mapping>
62
63 <servlet-mapping>
64 <servlet-name>ServiceBasedMcpServlet</servlet-name>
65 <url-pattern>/rpc/*</url-pattern>
66 </servlet-mapping>
67
68 <!-- Session Configuration -->
69 <session-config>
70 <session-timeout>30</session-timeout>
71 <cookie-config>
72 <http-only>true</http-only>
73 <secure>false</secure>
74 </cookie-config>
75 </session-config>
76
77 <!-- Security Constraints (optional - uncomment if needed) -->
78 <!--
79 <security-constraint>
80 <web-resource-collection>
81 <web-resource-name>MCP Endpoints</web-resource-name>
82 <url-pattern>/sse/*</url-pattern>
83 <url-pattern>/mcp/message/*</url-pattern>
84 <url-pattern>/rpc/*</url-pattern>
85 </web-resource-collection>
86 <auth-constraint>
87 <role-name>admin</role-name>
88 </auth-constraint>
89 </security-constraint>
90
91 <login-config>
92 <auth-method>BASIC</auth-method>
93 <realm-name>Moqui MCP</realm-name>
94 </login-config>
95 -->
96
97 <!-- MIME Type Mappings -->
98 <mime-mapping>
99 <extension>json</extension>
100 <mime-type>application/json</mime-type>
101 </mime-mapping>
102
103 <!-- Default Welcome Files -->
104 <welcome-file-list>
105 <welcome-file>index.html</welcome-file>
106 <welcome-file>index.jsp</welcome-file>
107 </welcome-file-list>
108
109 </web-app>
...\ No newline at end of file ...\ No newline at end of file
1 <?xml version="1.0" encoding="UTF-8"?>
2 <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
5 http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
6 version="4.0">
7
8 <!-- MCP SDK Services Servlet Configuration -->
9 <servlet>
10 <servlet-name>McpSdkServicesServlet</servlet-name>
11 <servlet-class>org.moqui.mcp.McpSdkServicesServlet</servlet-class>
12
13 <!-- Configuration parameters -->
14 <init-param>
15 <param-name>moqui-name</param-name>
16 <param-value>moqui-mcp-2</param-value>
17 </init-param>
18
19 <!-- Enable async support (required for SSE) -->
20 <async-supported>true</async-supported>
21
22 <!-- Load on startup to initialize MCP server -->
23 <load-on-startup>1</load-on-startup>
24 </servlet>
25
26 <!-- Servlet mappings for MCP SDK Services endpoints -->
27 <!-- The MCP SDK will handle both SSE and message endpoints through this servlet -->
28 <servlet-mapping>
29 <servlet-name>McpSdkServicesServlet</servlet-name>
30 <url-pattern>/mcp/*</url-pattern>
31 </servlet-mapping>
32
33 <!-- Session configuration -->
34 <session-config>
35 <session-timeout>30</session-timeout>
36 <cookie-config>
37 <http-only>true</http-only>
38 <secure>false</secure>
39 </cookie-config>
40 </session-config>
41
42 </web-app>
...\ No newline at end of file ...\ No newline at end of file
1 <?xml version="1.0" encoding="UTF-8"?>
2 <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
5 http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
6 version="4.0">
7
8 <!-- MCP SDK SSE Servlet Configuration -->
9 <servlet>
10 <servlet-name>McpSdkSseServlet</servlet-name>
11 <servlet-class>org.moqui.mcp.McpSdkSseServlet</servlet-class>
12
13 <!-- Configuration parameters -->
14 <init-param>
15 <param-name>moqui-name</param-name>
16 <param-value>moqui-mcp-2</param-value>
17 </init-param>
18
19 <!-- Enable async support (required for SSE) -->
20 <async-supported>true</async-supported>
21
22 <!-- Load on startup to initialize MCP server -->
23 <load-on-startup>1</load-on-startup>
24 </servlet>
25
26 <!-- Servlet mappings for MCP SDK SSE endpoints -->
27 <!-- The MCP SDK will handle both SSE and message endpoints through this servlet -->
28 <servlet-mapping>
29 <servlet-name>McpSdkSseServlet</servlet-name>
30 <url-pattern>/mcp/*</url-pattern>
31 </servlet-mapping>
32
33 <!-- Session configuration -->
34 <session-config>
35 <session-timeout>30</session-timeout>
36 <cookie-config>
37 <http-only>true</http-only>
38 <secure>false</secure>
39 </cookie-config>
40 </session-config>
41
42 </web-app>
...\ No newline at end of file ...\ No newline at end of file
1 <?xml version="1.0" encoding="UTF-8"?>
2 <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
5 http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
6 version="4.0">
7
8 <!-- MCP SSE Servlet Configuration -->
9 <servlet>
10 <servlet-name>McpSseServlet</servlet-name>
11 <servlet-class>org.moqui.mcp.McpSseServlet</servlet-class>
12
13 <!-- Configuration parameters -->
14 <init-param>
15 <param-name>moqui-name</param-name>
16 <param-value>moqui-mcp-2</param-value>
17 </init-param>
18
19 <init-param>
20 <param-name>sseEndpoint</param-name>
21 <param-value>/sse</param-value>
22 </init-param>
23
24 <init-param>
25 <param-name>messageEndpoint</param-name>
26 <param-value>/mcp/message</param-value>
27 </init-param>
28
29 <init-param>
30 <param-name>keepAliveIntervalSeconds</param-name>
31 <param-value>30</param-value>
32 </init-param>
33
34 <init-param>
35 <param-name>maxConnections</param-name>
36 <param-value>100</param-value>
37 </init-param>
38
39 <!-- Enable async support -->
40 <async-supported>true</async-supported>
41 </servlet>
42
43 <!-- Servlet mappings for MCP SSE endpoints -->
44 <servlet-mapping>
45 <servlet-name>McpSseServlet</servlet-name>
46 <url-pattern>/sse/*</url-pattern>
47 </servlet-mapping>
48
49 <servlet-mapping>
50 <servlet-name>McpSseServlet</servlet-name>
51 <url-pattern>/mcp/message/*</url-pattern>
52 </servlet-mapping>
53
54 <!-- Session configuration -->
55 <session-config>
56 <session-timeout>30</session-timeout>
57 <cookie-config>
58 <http-only>true</http-only>
59 <secure>false</secure>
60 </cookie-config>
61 </session-config>
62
63 </web-app>
...\ No newline at end of file ...\ No newline at end of file