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
#Fri Nov 14 19:52:12 CST 2025
gradle.version=8.9
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
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
connection.project.dir=../../..
eclipse.preferences.version=1
gradle.user.home=
java.home=/usr/lib/jvm/java-17-openjdk-amd64
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true
# Moqui MCP SDK Services Servlet
This document describes the MCP servlet implementation that combines the official Java MCP SDK with existing Moqui services for optimal architecture.
## Overview
The `McpSdkServicesServlet` provides the best of both worlds:
- **MCP SDK** for standards-compliant protocol handling and SSE transport
- **McpServices.xml** for proven business logic and Moqui integration
- **Delegation pattern** for clean separation of concerns
- **Service reuse** across different MCP transport implementations
## Architecture
### Component Interaction
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ MCP Client │───▶│ MCP SDK Servlet │───▶│ McpServices.xml│
│ │ │ │ │ │
│ - JSON-RPC 2.0 │ │ - Transport │ │ - Business │
│ - SSE Events │ │ - Protocol │ │ Logic │
│ - Tool Calls │ │ - Validation │ │ - Security │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌─────────────────┐
│ Moqui Framework│
│ │
│ - Entity Engine │
│ - Service Engine│
│ - Security │
└─────────────────┘
```
### Service Delegation Pattern
| MCP Method | SDK Servlet | Moqui Service | Description |
|------------|-------------|---------------|-------------|
| `initialize` | `initialize` tool | `mcp#Initialize` | Session initialization |
| `ping` | `ping` tool | `mcp#Ping` | Health check |
| `tools/list` | `tools/list` tool | `mcp#ToolsList` | Discover available tools |
| `tools/call` | `tools/call` tool | `mcp#ToolsCall` | Execute tools/services |
| `resources/list` | `resources/list` tool | `mcp#ResourcesList` | Discover entities |
| `resources/read` | `resources/read` tool | `mcp#ResourcesRead` | Read entity data |
## Benefits
### 1. Separation of Concerns
- **SDK Servlet**: Protocol handling, transport, validation
- **McpServices.xml**: Business logic, security, data access
- **Clean interfaces**: Well-defined service contracts
### 2. Code Reuse
- **Single source of truth**: All MCP logic in McpServices.xml
- **Multiple transports**: Same services work with different servlets
- **Testable services**: Can test business logic independently
### 3. Standards Compliance
- **MCP SDK**: Guaranteed protocol compliance
- **JSON Schema**: Automatic validation
- **Error handling**: Standardized responses
### 4. Moqui Integration
- **Security**: Leverages Moqui authentication/authorization
- **Auditing**: Built-in artifact hit tracking
- **Transactions**: Proper transaction management
- **Error handling**: Moqui exception handling
## Configuration
### Dependencies
```gradle
dependencies {
// MCP Java SDK for transport and protocol
implementation 'io.modelcontextprotocol.sdk:mcp-sdk:1.0.0'
// Jackson for JSON handling (required by MCP SDK)
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2'
// Servlet API
compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
}
```
### Web.xml Configuration
```xml
<servlet>
<servlet-name>McpSdkServicesServlet</servlet-name>
<servlet-class>org.moqui.mcp.McpSdkServicesServlet</servlet-class>
<init-param>
<param-name>moqui-name</param-name>
<param-value>moqui-mcp-2</param-value>
</init-param>
<async-supported>true</async-supported>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>McpSdkServicesServlet</servlet-name>
<url-pattern>/mcp/*</url-pattern>
</servlet-mapping>
```
## Available Tools
### Core MCP Tools
| Tool | Service | Description |
|------|---------|-------------|
| `initialize` | `mcp#Initialize` | Initialize MCP session with capabilities negotiation |
| `ping` | `mcp#Ping` | Health check and server status |
| `tools/list` | `mcp#ToolsList` | List all available Moqui services as tools |
| `tools/call` | `mcp#ToolsCall` | Execute any Moqui service |
| `resources/list` | `mcp#ResourcesList` | List all accessible entities as resources |
| `resources/read` | `mcp#ResourcesRead` | Read entity data and metadata |
| `debug/component` | `debug#ComponentStatus` | Debug component status and configuration |
### Tool Examples
#### Initialize Session
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "init-1",
"method": "tools/call",
"params": {
"name": "initialize",
"arguments": {
"protocolVersion": "2025-06-18",
"clientInfo": {
"name": "Test Client",
"version": "1.0.0"
}
}
}
}'
```
#### List Available Tools
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "tools-1",
"method": "tools/call",
"params": {
"name": "tools/list",
"arguments": {}
}
}'
```
#### Execute Moqui Service
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "call-1",
"method": "tools/call",
"params": {
"name": "tools/call",
"arguments": {
"name": "org.moqui.entity.EntityServices.find#List",
"arguments": {
"entityName": "moqui.security.UserAccount",
"fields": ["username", "emailAddress"],
"limit": 10
}
}
}
}'
```
## Available Resources
### Entity Resources
- **`entity://`** - List all entities
- **`entity://EntityName`** - Read specific entity data
#### Resource Examples
```bash
# List all entities
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "res-1",
"method": "tools/call",
"params": {
"name": "resources/list",
"arguments": {}
}
}'
# Read specific entity
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "res-2",
"method": "tools/call",
"params": {
"name": "resources/read",
"arguments": {
"uri": "entity://moqui.security.UserAccount"
}
}
}'
```
## Available Prompts
### Entity Query Prompt
Generate Moqui entity queries:
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "prompt-1",
"method": "prompts/get",
"params": {
"name": "entity-query",
"arguments": {
"entity": "mantle.order.OrderHeader",
"purpose": "Find recent orders",
"fields": "orderId, orderDate, grandTotal"
}
}
}'
```
### Service Execution Prompt
Generate Moqui service execution plans:
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "prompt-2",
"method": "prompts/get",
"params": {
"name": "service-execution",
"arguments": {
"serviceName": "create#OrderItem",
"description": "Create a new order item",
"parameters": "{\"orderId\":\"string\",\"productId\":\"string\",\"quantity\":\"number\"}"
}
}
}'
```
## Service Implementation Details
### McpServices.xml Features
The `McpServices.xml` provides comprehensive MCP functionality:
#### 1. Service Discovery (`mcp#ToolsList`)
```xml
<service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true">
<description>Handle MCP tools/list request with direct Moqui service discovery</description>
<actions>
<script><![CDATA[
// Get all service names from Moqui service engine
def allServiceNames = ec.service.getKnownServiceNames()
def availableTools = []
// Convert services to MCP tools
for (serviceName in allServiceNames) {
if (ec.service.hasPermission(serviceName)) {
def serviceInfo = ec.service.getServiceInfo(serviceName)
// Convert to MCP tool format...
}
}
]]></script>
</actions>
</service>
```
#### 2. Service Execution (`mcp#ToolsCall`)
```xml
<service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true">
<description>Handle MCP tools/call request with direct Moqui service execution</description>
<actions>
<script><![CDATA[
// Validate service exists and user has permission
if (!ec.service.hasPermission(name)) {
throw new Exception("Permission denied for tool: ${name}")
}
// Create audit record
def artifactHit = ec.entity.makeValue("moqui.server.ArtifactHit")
// ... audit setup ...
// Execute service
def serviceResult = ec.service.sync().name(name).parameters(arguments).call()
// Convert result to MCP format
result = [content: content, isError: false]
]]></script>
</actions>
</service>
```
#### 3. Entity Access (`mcp#ResourcesRead`)
```xml
<service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true">
<description>Handle MCP resources/read request with Moqui entity queries</description>
<actions>
<script><![CDATA[
// Parse entity URI and validate permissions
if (!ec.user.hasPermission("entity:${entityName}", "VIEW")) {
throw new Exception("Permission denied for entity: ${entityName}")
}
// Get entity definition and query data
def entityDef = ec.entity.getEntityDefinition(entityName)
def entityList = ec.entity.find(entityName).limit(100).list()
// Build comprehensive response
result = [contents: [[
uri: uri,
mimeType: "application/json",
text: entityInfo + data
]]]
]]></script>
</actions>
</service>
```
## Security Model
### Authentication Integration
- **Moqui Authentication**: Uses existing Moqui user accounts
- **Session Management**: Leverages Moqui web sessions
- **Permission Checking**: Validates service and entity permissions
- **Audit Trail**: Records all MCP operations in ArtifactHit
### Permission Model
| Operation | Permission Check | Description |
|------------|------------------|-------------|
| Service Execution | `ec.service.hasPermission(serviceName)` | Check service-level permissions |
| Entity Access | `ec.user.hasPermission("entity:EntityName", "VIEW")` | Check entity-level permissions |
| Resource Access | `ec.user.hasPermission("entity:EntityName", "VIEW")` | Check entity read permissions |
### Audit Logging
All MCP operations are automatically logged:
```groovy
def artifactHit = ec.entity.makeValue("moqui.server.ArtifactHit")
artifactHit.artifactType = "MCP"
artifactHit.artifactSubType = "Tool" // or "Resource"
artifactHit.artifactName = serviceName
artifactHit.parameterString = new JsonBuilder(arguments).toString()
artifactHit.userId = ec.user.userId
artifactHit.create()
```
## Error Handling
### Service-Level Errors
The McpServices.xml provides comprehensive error handling:
```groovy
try {
def serviceResult = ec.service.sync().name(name).parameters(arguments).call()
result = [content: content, isError: false]
} catch (Exception e) {
// Update audit record with error
artifactHit.wasError = "Y"
artifactHit.errorMessage = e.message
artifactHit.update()
result = [
content: [[type: "text", text: "Error executing tool ${name}: ${e.message}"]],
isError: true
]
}
```
### SDK-Level Errors
The MCP SDK handles protocol-level errors:
- **JSON-RPC validation** - Invalid requests
- **Schema validation** - Parameter validation
- **Transport errors** - Connection issues
- **Timeout handling** - Long-running operations
## Performance Considerations
### Service Caching
- **Service definitions** cached by Moqui service engine
- **Entity metadata** cached by entity engine
- **Permission checks** cached for performance
- **Audit records** batched for efficiency
### Connection Management
- **SSE connections** managed by MCP SDK
- **Async processing** for non-blocking operations
- **Connection limits** prevent resource exhaustion
- **Graceful shutdown** on servlet destroy
### Query Optimization
- **Entity queries** limited to 100 records by default
- **Field selection** reduces data transfer
- **Pagination support** for large result sets
- **Index utilization** through proper query construction
## Monitoring and Debugging
### Component Status Tool
Use the debug tool to check component status:
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "debug-1",
"method": "tools/call",
"params": {
"name": "debug/component",
"arguments": {}
}
}'
```
### Log Categories
- `org.moqui.mcp.McpSdkServicesServlet` - Servlet operations
- `org.moqui.mcp.McpServices` - Service operations
- `io.modelcontextprotocol.sdk` - MCP SDK operations
### Audit Reports
Query audit records:
```sql
SELECT artifactName, COUNT(*) as callCount,
AVG(runningTimeMillis) as avgTime,
COUNT(CASE WHEN wasError = 'Y' THEN 1 END) as errorCount
FROM moqui.server.ArtifactHit
WHERE artifactType = 'MCP'
GROUP BY artifactName
ORDER BY callCount DESC;
```
## Comparison: Approaches
| Feature | Custom Servlet | SDK + Custom Logic | SDK + Services |
|---------|----------------|-------------------|-----------------|
| **Protocol Compliance** | Manual | SDK | SDK |
| **Business Logic** | Custom | Custom | Services |
| **Code Reuse** | Low | Medium | High |
| **Testing** | Complex | Medium | Simple |
| **Maintenance** | High | Medium | Low |
| **Security** | Custom | Custom | Moqui |
| **Auditing** | Custom | Custom | Built-in |
| **Performance** | Unknown | Good | Optimized |
## Migration Guide
### From Custom Implementation
1. **Add MCP SDK dependency** to build.gradle
2. **Deploy McpServices.xml** (if not already present)
3. **Replace servlet** with `McpSdkServicesServlet`
4. **Update web.xml** configuration
5. **Test existing clients** for compatibility
6. **Update documentation** with new tool names
### Client Compatibility
The SDK + Services approach maintains compatibility:
- **Same JSON-RPC 2.0 protocol**
- **Enhanced tool interfaces** (more services available)
- **Better error responses** (standardized)
- **Improved security** (Moqui integration)
## Best Practices
### 1. Service Design
- **Keep services focused** on single responsibilities
- **Use proper error handling** with meaningful messages
- **Document parameters** with descriptions and types
- **Validate inputs** before processing
### 2. Security
- **Always check permissions** before operations
- **Use parameterized queries** to prevent injection
- **Log all access** for audit trails
- **Sanitize outputs** to prevent data leakage
### 3. Performance
- **Limit result sets** to prevent memory issues
- **Use appropriate indexes** for entity queries
- **Cache frequently accessed data**
- **Monitor execution times** for optimization
### 4. Error Handling
- **Provide meaningful error messages**
- **Include context** for debugging
- **Log errors appropriately**
- **Graceful degradation** for failures
## Future Enhancements
### Planned Features
1. **Async Services** - Support for long-running operations
2. **Streaming Resources** - Large dataset streaming
3. **Tool Categories** - Organize services by domain
4. **Dynamic Registration** - Runtime service addition
5. **Enhanced Prompts** - More sophisticated prompt templates
### Advanced Integration
1. **Event-Driven Updates** - Real-time entity change notifications
2. **Workflow Integration** - MCP-triggered Moqui workflows
3. **Multi-tenancy** - Tenant-aware MCP operations
4. **GraphQL Support** - GraphQL as alternative to entity access
## References
- [MCP Java SDK Documentation](https://modelcontextprotocol.io/sdk/java/mcp-server)
- [MCP Protocol Specification](https://modelcontextprotocol.io/specification/)
- [Moqui Framework Documentation](https://moqui.org/docs/)
- [Moqui Service Engine](https://moqui.org/docs/docs/moqui-service-engine)
- [Moqui Entity Engine](https://moqui.org/docs/docs/moqui-entity-engine)
\ No newline at end of file
# Moqui MCP SDK Servlet
This document describes the MCP servlet implementation using the official Java MCP SDK library (`io.modelcontextprotocol.sdk`).
## Overview
The `McpSdkSseServlet` uses the official MCP Java SDK to provide standards-compliant MCP server functionality with:
- **Full MCP protocol compliance** using official SDK
- **SSE transport** with proper session management
- **Built-in tool/resource/prompt registration**
- **JSON schema validation** for all inputs
- **Standard error handling** and response formatting
- **Async support** for scalable connections
## Architecture
### MCP SDK Integration
The servlet uses these key MCP SDK components:
- `HttpServletSseServerTransportProvider` - SSE transport implementation
- `McpSyncServer` - Synchronous MCP server API
- `McpServerFeatures.SyncToolSpecification` - Tool definitions
- `McpServerFeatures.SyncResourceSpecification` - Resource definitions
- `McpServerFeatures.SyncPromptSpecification` - Prompt definitions
### Endpoint Structure
```
/mcp/sse - SSE endpoint for server-to-client events (handled by SDK)
/mcp/message - Message endpoint for client-to-server requests (handled by SDK)
```
## Dependencies
### Build Configuration
Add to `build.gradle`:
```gradle
dependencies {
// MCP Java SDK
implementation 'io.modelcontextprotocol.sdk:mcp-sdk:1.0.0'
// Jackson for JSON handling (required by MCP SDK)
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2'
// Servlet API
compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
}
```
### Maven Dependencies
```xml
<dependencies>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-sdk</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.15.2</version>
</dependency>
</dependencies>
```
## Configuration
### Web.xml Configuration
```xml
<servlet>
<servlet-name>McpSdkSseServlet</servlet-name>
<servlet-class>org.moqui.mcp.McpSdkSseServlet</servlet-class>
<init-param>
<param-name>moqui-name</param-name>
<param-value>moqui-mcp-2</param-value>
</init-param>
<async-supported>true</async-supported>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>McpSdkSseServlet</servlet-name>
<url-pattern>/mcp/*</url-pattern>
</servlet-mapping>
```
## Available Tools
### Entity Tools
| Tool | Description | Parameters |
|------|-------------|------------|
| `EntityFind` | Query entities | `entity` (required), `fields`, `constraint`, `limit`, `offset` |
| `EntityCreate` | Create entity records | `entity` (required), `fields` (required) |
| `EntityUpdate` | Update entity records | `entity` (required), `fields` (required), `constraint` |
| `EntityDelete` | Delete entity records | `entity` (required), `constraint` |
### Service Tools
| Tool | Description | Parameters |
|------|-------------|------------|
| `ServiceCall` | Execute Moqui services | `service` (required), `parameters` |
| `SystemStatus` | Get system statistics | `includeMetrics`, `includeCache` |
## Available Resources
### Entity Resources
- `entity://` - List all entities
- `entity://EntityName` - Get specific entity definition
**Example:**
```bash
# List all entities
curl -H "Accept: application/json" \
http://localhost:8080/mcp/resources?uri=entity://
# Get specific entity
curl -H "Accept: application/json" \
http://localhost:8080/mcp/resources?uri=entity://moqui.security.UserAccount
```
## Available Prompts
### Entity Query Prompt
Generate Moqui entity queries:
```json
{
"name": "entity-query",
"arguments": {
"entity": "mantle.order.OrderHeader",
"purpose": "Find recent orders",
"fields": "orderId, orderDate, grandTotal"
}
}
```
### Service Definition Prompt
Generate Moqui service definitions:
```json
{
"name": "service-definition",
"arguments": {
"serviceName": "create#OrderItem",
"description": "Create a new order item",
"parameters": "{\"orderId\":\"string\",\"productId\":\"string\",\"quantity\":\"number\"}"
}
}
```
## Usage Examples
### Client Connection
```javascript
// Using MCP SDK client
import { McpClient } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const transport = new StdioClientTransport({
command: 'curl',
args: ['-X', 'POST', 'http://localhost:8080/mcp/message']
});
const client = new McpClient(
transport,
{
name: "Moqui Client",
version: "1.0.0"
}
);
await client.connect();
// List tools
const tools = await client.listTools();
console.log('Available tools:', tools.tools);
// Call a tool
const result = await client.callTool({
name: "EntityFind",
arguments: {
entity: "moqui.security.UserAccount",
limit: 10
}
});
console.log('Tool result:', result);
```
### Raw HTTP Client
```bash
# Initialize connection
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "init-1",
"method": "initialize",
"params": {
"clientInfo": {
"name": "Test Client",
"version": "1.0.0"
}
}
}'
# List tools
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "tools-1",
"method": "tools/list",
"params": {}
}'
# Call EntityFind tool
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "call-1",
"method": "tools/call",
"params": {
"name": "EntityFind",
"arguments": {
"entity": "moqui.security.UserAccount",
"fields": ["username", "emailAddress"],
"limit": 5
}
}
}'
```
## Tool Implementation Details
### EntityFind Tool
```groovy
def entityFindTool = new McpServerFeatures.SyncToolSpecification(
new Tool("EntityFind", "Find entities in Moqui using entity queries", """
{
"type": "object",
"properties": {
"entity": {"type": "string", "description": "Entity name"},
"fields": {"type": "array", "items": {"type": "string"}, "description": "Fields to select"},
"constraint": {"type": "string", "description": "Constraint expression"},
"limit": {"type": "number", "description": "Maximum results"},
"offset": {"type": "number", "description": "Results offset"}
},
"required": ["entity"]
}
"""),
{ exchange, arguments ->
return executeWithEc { ec ->
String entityName = arguments.get("entity") as String
List<String> fields = arguments.get("fields") as List<String>
String constraint = arguments.get("constraint") as String
Integer limit = arguments.get("limit") as Integer
Integer offset = arguments.get("offset") as Integer
def finder = ec.entity.find(entityName).selectFields(fields ?: ["*"])
if (constraint) {
finder.condition(constraint)
}
if (offset) {
finder.offset(offset)
}
if (limit) {
finder.limit(limit)
}
def result = finder.list()
new CallToolResult([
new TextContent("Found ${result.size()} records in ${entityName}: ${result}")
], false)
}
}
)
```
## Error Handling
The MCP SDK provides standardized error handling:
### JSON-RPC Errors
| Code | Description | Example |
|------|-------------|---------|
| `-32600` | Invalid Request | Malformed JSON-RPC |
| `-32601` | Method Not Found | Unknown method name |
| `-32602` | Invalid Params | Missing required parameters |
| `-32603` | Internal Error | Server-side exception |
### Tool Execution Errors
```groovy
return new CallToolResult([
new TextContent("Error: " + e.message)
], true) // isError = true
```
### Resource Errors
```groovy
throw new IllegalArgumentException("Entity not found: ${entityName}")
```
## Security
### Authentication Integration
The servlet integrates with Moqui's security framework:
```groovy
// Authenticate as admin for MCP operations
if (!ec.user?.userId) {
try {
ec.user.loginUser("admin", "admin")
} catch (Exception e) {
logger.warn("MCP Admin login failed: ${e.message}")
}
}
```
### CORS Support
```groovy
private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName) {
String originHeader = request.getHeader("Origin")
if (originHeader) {
response.setHeader("Access-Control-Allow-Origin", originHeader)
response.setHeader("Access-Control-Allow-Credentials", "true")
}
String methodHeader = request.getHeader("Access-Control-Request-Method")
if (methodHeader) {
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
response.setHeader("Access-Control-Max-Age", "3600")
return true
}
return false
}
```
## Monitoring and Logging
### Log Categories
- `org.moqui.mcp.McpSdkSseServlet` - Servlet operations
- `io.modelcontextprotocol.sdk` - MCP SDK operations
### Key Log Messages
```
INFO - McpSdkSseServlet initialized for webapp moqui-mcp-2
INFO - MCP Server started with SSE transport
INFO - Registered 6 Moqui tools
INFO - Registered Moqui resources
INFO - Registered Moqui prompts
```
## Performance Considerations
### Connection Management
- **Async processing** for non-blocking operations
- **Connection pooling** handled by servlet container
- **Session management** provided by MCP SDK
- **Graceful shutdown** on servlet destroy
### Memory Usage
- **Tool definitions** loaded once at startup
- **ExecutionContext** created per request
- **JSON parsing** handled by Jackson
- **SSE connections** managed efficiently by SDK
## Comparison: Custom vs SDK Implementation
| Feature | Custom Implementation | SDK Implementation |
|---------|---------------------|-------------------|
| **Protocol Compliance** | Manual implementation | Guaranteed by SDK |
| **Error Handling** | Custom code | Standardized |
| **JSON Schema** | Manual validation | Automatic |
| **SSE Transport** | Custom implementation | Built-in |
| **Session Management** | Manual code | Built-in |
| **Testing** | Custom tests | SDK tested |
| **Maintenance** | High effort | Low effort |
| **Standards Updates** | Manual updates | Automatic with SDK |
## Migration from Custom Servlet
### Benefits of SDK Migration
1. **Standards Compliance** - Guaranteed MCP protocol compliance
2. **Reduced Maintenance** - Less custom code to maintain
3. **Better Error Handling** - Standardized error responses
4. **Future Compatibility** - Automatic updates with SDK releases
5. **Testing** - SDK includes comprehensive test suite
### Migration Steps
1. **Add MCP SDK dependency** to build.gradle
2. **Replace servlet class** with `McpSdkSseServlet`
3. **Update web.xml** configuration
4. **Test existing clients** for compatibility
5. **Update documentation** with new endpoints
### Client Compatibility
The SDK implementation maintains compatibility with existing MCP clients:
- Same JSON-RPC 2.0 protocol
- Same tool/resource/prompt interfaces
- Same authentication patterns
- Better error responses and validation
## Troubleshooting
### Common Issues
1. **Dependency Conflicts**
```bash
# Check for Jackson version conflicts
./gradlew dependencies | grep jackson
```
2. **Async Support Issues**
```xml
<!-- Ensure async is enabled in web.xml -->
<async-supported>true</async-supported>
```
3. **Servlet Container Compatibility**
- Requires Servlet 3.0+ for async support
- Test with Tomcat 8.5+, Jetty 9.4+, or similar
4. **MCP SDK Version**
```gradle
// Use latest stable version
implementation 'io.modelcontextprotocol.sdk:mcp-sdk:1.0.0'
```
### Debug Mode
Enable debug logging:
```xml
<Logger name="org.moqui.mcp.McpSdkSseServlet" level="DEBUG"/>
<Logger name="io.modelcontextprotocol.sdk" level="DEBUG"/>
```
## Future Enhancements
### Planned Features
1. **Async Tools** - Use `McpServerFeatures.AsyncToolSpecification`
2. **Streaming Resources** - Large data set streaming
3. **Tool Categories** - Organize tools by domain
4. **Dynamic Registration** - Runtime tool/resource addition
5. **Metrics Collection** - Performance and usage metrics
### Advanced Integration
1. **Spring Integration** - Use MCP SDK Spring starters
2. **WebFlux Support** - Reactive transport providers
3. **WebSocket Transport** - Alternative to SSE
4. **Load Balancing** - Multi-instance deployment
## References
- [MCP Java SDK Documentation](https://modelcontextprotocol.io/sdk/java/mcp-server)
- [MCP Protocol Specification](https://modelcontextprotocol.io/specification/)
- [Moqui Framework Documentation](https://moqui.org/docs/)
- [Servlet 3.0 Specification](https://jakarta.ee/specifications/servlet/3.0/)
\ No newline at end of file
# Moqui MCP SSE Servlet
This document describes the new MCP SSE (Server-Sent Events) servlet implementation based on the official Java MCP SDK SSE Servlet approach.
## Overview
The `McpSseServlet` implements the MCP SSE transport specification using the traditional Servlet API, providing:
- **Asynchronous message handling** using Servlet async support
- **Session management** for multiple client connections
- **Two types of endpoints**:
- SSE endpoint (`/sse`) for server-to-client events
- Message endpoint (`/mcp/message`) for client-to-server requests
- **Error handling** and response formatting
- **Graceful shutdown** support
- **Keep-alive pings** to maintain connections
- **Connection limits** to prevent resource exhaustion
## Architecture
### Endpoint Structure
```
/sse - SSE endpoint for server-to-client events
/mcp/message - Message endpoint for client-to-server requests
```
### Connection Flow
1. **Client connects** to `/sse` endpoint
2. **Server establishes** SSE connection with unique session ID
3. **Client sends** JSON-RPC messages to `/mcp/message`
4. **Server processes** messages and sends responses via SSE
5. **Keep-alive pings** maintain connection health
## Configuration
### Servlet Configuration Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `moqui-name` | - | Moqui webapp name (required) |
| `sseEndpoint` | `/sse` | SSE endpoint path |
| `messageEndpoint` | `/mcp/message` | Message endpoint path |
| `keepAliveIntervalSeconds` | `30` | Keep-alive ping interval |
| `maxConnections` | `100` | Maximum concurrent SSE connections |
### Web.xml Configuration
```xml
<servlet>
<servlet-name>McpSseServlet</servlet-name>
<servlet-class>org.moqui.mcp.McpSseServlet</servlet-class>
<init-param>
<param-name>moqui-name</param-name>
<param-value>moqui-mcp-2</param-value>
</init-param>
<init-param>
<param-name>keepAliveIntervalSeconds</param-name>
<param-value>30</param-value>
</init-param>
<async-supported>true</async-supported>
</servlet>
<servlet-mapping>
<servlet-name>McpSseServlet</servlet-name>
<url-pattern>/sse/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>McpSseServlet</servlet-name>
<url-pattern>/mcp/message/*</url-pattern>
</servlet-mapping>
```
## Usage Examples
### Client Connection Flow
#### 1. Establish SSE Connection
```bash
curl -N -H "Accept: text/event-stream" \
http://localhost:8080/sse
```
**Response:**
```
event: connect
data: {"type":"connected","sessionId":"uuid-123","timestamp":1234567890,"serverInfo":{"name":"Moqui MCP SSE Server","version":"2.0.0","protocolVersion":"2025-06-18"}}
event: ping
data: {"type":"ping","timestamp":1234567890,"connections":1}
```
#### 2. Initialize MCP Session
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "init-1",
"method": "initialize",
"params": {
"clientInfo": {
"name": "Test Client",
"version": "1.0.0"
}
}
}'
```
**Response:**
```json
{
"jsonrpc": "2.0",
"id": "init-1",
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {"listChanged": true},
"resources": {"subscribe": true, "listChanged": true},
"logging": {},
"notifications": {}
},
"serverInfo": {
"name": "Moqui MCP SSE Server",
"version": "2.0.0"
},
"sessionId": "uuid-123"
}
}
```
#### 3. List Available Tools
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "tools-1",
"method": "tools/list",
"params": {}
}'
```
#### 4. Call a Tool
```bash
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "call-1",
"method": "tools/call",
"params": {
"name": "EntityFind",
"arguments": {
"entity": "moqui.security.UserAccount",
"fields": ["username", "emailAddress"],
"limit": 10
}
}
}'
```
### JavaScript Client Example
```javascript
class McpSseClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.sessionId = null;
this.eventSource = null;
this.messageId = 0;
}
async connect() {
// Establish SSE connection
this.eventSource = new EventSource(`${this.baseUrl}/sse`);
this.eventSource.addEventListener('connect', (event) => {
const data = JSON.parse(event.data);
console.log('Connected:', data);
});
this.eventSource.addEventListener('ping', (event) => {
const data = JSON.parse(event.data);
console.log('Ping:', data);
});
this.eventSource.addEventListener('initialized', (event) => {
const data = JSON.parse(event.data);
console.log('Server initialized:', data);
});
// Initialize MCP session
const initResponse = await this.sendMessage('initialize', {
clientInfo: {
name: 'JavaScript Client',
version: '1.0.0'
}
});
this.sessionId = initResponse.sessionId;
return initResponse;
}
async sendMessage(method, params = {}) {
const id = `msg-${++this.messageId}`;
const payload = {
jsonrpc: "2.0",
id: id,
method: method,
params: params
};
const response = await fetch(`${this.baseUrl}/mcp/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
return await response.json();
}
async listTools() {
return await this.sendMessage('tools/list');
}
async callTool(name, arguments) {
return await this.sendMessage('tools/call', {
name: name,
arguments: arguments
});
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
}
}
}
// Usage
const client = new McpSseClient('http://localhost:8080');
client.connect().then(() => {
console.log('MCP client connected');
// List tools
return client.listTools();
}).then(tools => {
console.log('Available tools:', tools);
// Call a tool
return client.callTool('EntityFind', {
entity: 'moqui.security.UserAccount',
limit: 5
});
}).then(result => {
console.log('Tool result:', result);
}).catch(error => {
console.error('MCP client error:', error);
});
```
## Features
### Session Management
- **Unique session IDs** generated for each connection
- **Connection tracking** with metadata (user agent, timestamps)
- **Automatic cleanup** on connection close/error
- **Connection limits** to prevent resource exhaustion
### Event Types
| Event Type | Description | Data |
|------------|-------------|------|
| `connect` | Initial connection established | Session info, server details |
| `ping` | Keep-alive ping | Timestamp, connection count |
| `initialized` | Server initialization notification | Session info, client details |
| `subscribed` | Subscription confirmation | Session, event type |
| `tool_result` | Tool execution result | Tool name, result data |
| `resource_update` | Resource change notification | Resource URI, change data |
### Error Handling
- **JSON-RPC 2.0 compliant** error responses
- **Connection error recovery** with automatic cleanup
- **Resource exhaustion protection** with connection limits
- **Graceful degradation** on service failures
### Security
- **CORS support** for cross-origin requests
- **Moqui authentication integration** with admin fallback
- **Session isolation** between clients
- **Input validation** for all requests
## Monitoring
### Connection Status
Get current server status:
```bash
curl http://localhost:8080/mcp/message
```
**Response:**
```json
{
"serverInfo": {
"name": "Moqui MCP SSE Server",
"version": "2.0.0",
"protocolVersion": "2025-06-18"
},
"connections": {
"active": 3,
"max": 100
},
"endpoints": {
"sse": "/sse",
"message": "/mcp/message"
}
}
```
### Logging
The servlet provides detailed logging for:
- Connection establishment/cleanup
- Message processing
- Error conditions
- Performance metrics
Log levels:
- `INFO`: Connection events, message processing
- `WARN`: Connection errors, authentication issues
- `ERROR`: System errors, service failures
- `DEBUG`: Detailed request/response data
## Comparison with Existing Servlet
| Feature | MoquiMcpServlet | McpSseServlet |
|---------|------------------|---------------|
| **Transport** | HTTP POST/GET | SSE + HTTP |
| **Async Support** | Limited | Full async |
| **Session Management** | Basic | Advanced |
| **Real-time Events** | No | Yes |
| **Connection Limits** | No | Yes |
| **Keep-alive** | No | Yes |
| **Standards Compliance** | Custom | MCP SSE Spec |
## Migration Guide
### From MoquiMcpServlet to McpSseServlet
1. **Update web.xml** to use the new servlet
2. **Update client code** to handle SSE connections
3. **Modify endpoints** from `/mcp/rpc` to `/mcp/message`
4. **Add SSE handling** for real-time events
5. **Update authentication** if using custom auth
### Client Migration
**Old approach:**
```bash
curl -X POST http://localhost:8080/mcp/rpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"ping","id":1}'
```
**New approach:**
```bash
# 1. Establish SSE connection
curl -N -H "Accept: text/event-stream" http://localhost:8080/sse &
# 2. Send messages
curl -X POST http://localhost:8080/mcp/message \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"ping","id":1}'
```
## Troubleshooting
### Common Issues
1. **SSE Connection Fails**
- Check async support is enabled in web.xml
- Verify servlet container supports Servlet 3.0+
- Check firewall/proxy settings
2. **Messages Not Received**
- Verify session ID is valid
- Check connection is still active
- Review server logs for errors
3. **High Memory Usage**
- Reduce `maxConnections` parameter
- Check for connection leaks
- Monitor session cleanup
4. **Authentication Issues**
- Verify Moqui security configuration
- Check admin user credentials
- Review webapp security settings
### Debug Mode
Enable debug logging by adding to log4j2.xml:
```xml
<Logger name="org.moqui.mcp.McpSseServlet" level="DEBUG" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
```
## Performance Considerations
### Connection Scaling
- **Default limit**: 100 concurrent connections
- **Memory usage**: ~1KB per connection
- **CPU overhead**: Minimal for idle connections
- **Network bandwidth**: Low (keep-alive pings only)
### Optimization Tips
1. **Adjust keep-alive interval** based on network conditions
2. **Monitor connection counts** and adjust limits
3. **Use connection pooling** for high-frequency clients
4. **Implement backpressure** for high-volume scenarios
## Future Enhancements
Planned improvements:
1. **WebSocket support** as alternative to SSE
2. **Message queuing** for offline clients
3. **Load balancing** support for multiple instances
4. **Advanced authentication** with token-based auth
5. **Metrics and monitoring** integration
6. **Message compression** for large payloads
## References
- [MCP Specification](https://modelcontextprotocol.io/)
- [Java MCP SDK](https://modelcontextprotocol.io/sdk/java/mcp-server)
- [Servlet 3.0 Specification](https://jakarta.ee/specifications/servlet/3.0/)
- [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
\ No newline at end of file
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, the author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
apply plugin: 'groovy'
def componentNode = parseComponent(project)
version = componentNode.'@version'
def jarBaseName = componentNode.'@name'
def moquiDir = projectDir.parentFile.parentFile.parentFile
def frameworkDir = file(moquiDir.absolutePath + '/framework')
repositories {
flatDir name: 'localLib', dirs: frameworkDir.absolutePath + '/lib'
mavenCentral()
}
// Log4J has annotation processors, disable to avoid warning
tasks.withType(JavaCompile) { options.compilerArgs << "-proc:none" }
tasks.withType(GroovyCompile) { options.compilerArgs << "-proc:none" }
dependencies {
implementation project(':framework')
// Servlet API (provided by framework, but needed for compilation)
compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
}
// by default the Java plugin runs test on build, change to not do that (only run test if explicit task)
check.dependsOn.clear()
task cleanLib(type: Delete) { delete fileTree(dir: projectDir.absolutePath+'/lib', include: '*') }
clean.dependsOn cleanLib
jar {
destinationDirectory = file(projectDir.absolutePath + '/lib')
archiveBaseName = jarBaseName
}
task copyDependencies { doLast {
copy { from (configurations.runtimeClasspath - project(':framework').configurations.runtimeClasspath)
into file(projectDir.absolutePath + '/lib') }
} }
copyDependencies.dependsOn cleanLib
jar.dependsOn copyDependencies
\ No newline at end of file
......@@ -14,13 +14,49 @@
xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/component-definition-3.xsd"
name="moqui-mcp-2">
<!-- No dependencies - uses only core framework -->
<!-- MCP SDK dependencies -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.16.0</version>
</dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-core</artifactId>
<version>0.16.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.5.10</version>
</dependency>
<entity-factory load-path="entity/" />
<service-factory load-path="service/" />
<screen-factory load-path="screen/" />
<!-- <screen-factory load-path="screen/" /> -->
<!-- Load seed data -->
<entity-factory load-data="data/McpSecuritySeedData.xml" />
<!-- Register MCP filter
<webapp-list>
<webapp name="webroot">
<filter name="McpFilter" class="org.moqui.mcp.McpFilter">
<url-pattern>/mcpservlet/*</url-pattern>
</filter>
</webapp>
</webapp-list> -->
</component>
......
No preview for this file type
<?xml version="1.0" encoding="UTF-8"?>
<!-- This software is in the public domain under CC0 1.0 Universal plus a
Grant of Patent License.
To the extent possible under law, author(s) have dedicated all
copyright and related and neighboring rights to this software to the
public domain worldwide. This software is distributed without any warranty.
You should have received a copy of the CC0 Public Domain Dedication
along with this software (see the LICENSE.md file). If not, see
<https://creativecommons.org/publicdomain/zero/1.0/>. -->
<screen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/xml-screen-3.xsd"
require-authentication="false" track-artifact-hit="false" default-menu-include="false">
<parameter name="jsonrpc"/>
<parameter name="id"/>
<parameter name="method"/>
<parameter name="params"/>
<actions>
<script><![CDATA[
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import java.util.UUID
// DEBUG: Log initial request details
ec.logger.info("=== MCP SCREEN REQUEST START ===")
ec.logger.info("MCP Screen Request - Method: ${ec.web?.request?.method}, ID: ${id}")
ec.logger.info("MCP Screen Request - Params: ${params}")
ec.logger.info("MCP Screen Request - User: ${ec.user.username}, UserID: ${ec.user.userId}")
ec.logger.info("MCP Screen Request - Current Time: ${ec.user.getNowTimestamp()}")
// Check MCP protocol version header
def protocolVersion = ec.web.request.getHeader("MCP-Protocol-Version")
if (!protocolVersion) {
protocolVersion = "2025-06-18" // Default to latest supported
}
ec.logger.info("MCP Protocol Version: ${protocolVersion}")
// Validate HTTP method - only POST for JSON-RPC, GET for SSE streams
def httpMethod = ec.web?.request?.method
ec.logger.info("Validating HTTP method: ${httpMethod}")
// Handle GET requests for SSE streams
if (httpMethod == "GET") {
ec.logger.info("GET request detected - checking for SSE support")
def acceptHeader = ec.web?.request?.getHeader("Accept")
ec.logger.info("GET Accept header: ${acceptHeader}")
if (acceptHeader?.contains("text/event-stream")) {
ec.logger.info("Client wants SSE stream - starting SSE")
// Set SSE headers
ec.web.response.setContentType("text/event-stream")
ec.web.response.setCharacterEncoding("UTF-8")
ec.web.response.setHeader("Cache-Control", "no-cache")
ec.web.response.setHeader("Connection", "keep-alive")
ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
def writer = ec.web.response.writer
try {
// Send initial connection event
writer.write("event: connected\n")
writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
writer.write("\n")
writer.flush()
// Keep connection alive with periodic pings
def count = 0
while (count < 30) { // Keep alive for ~30 seconds
Thread.sleep(1000)
writer.write("event: ping\n")
writer.write("data: {\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
writer.write("\n")
writer.flush()
count++
}
} catch (Exception e) {
ec.logger.warn("SSE stream interrupted: ${e.message}")
} finally {
writer.close()
}
return
} else {
// For GET requests without SSE Accept, return 405 as expected by MCP spec
ec.logger.info("GET request without SSE Accept - returning 405 Method Not Allowed")
throw new org.moqui.BaseException("Method Not Allowed. Use POST for JSON-RPC requests.")
}
}
if (httpMethod != "POST") {
ec.logger.warn("Invalid HTTP method: ${httpMethod}, expected POST")
throw new org.moqui.BaseException("Method Not Allowed. Use POST for JSON-RPC requests.")
}
ec.logger.info("HTTP method validation passed")
]]></script>
</actions>
<transition name="rpc" method="post" require-session-token="false">
<actions>
<script><![CDATA[
import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import java.util.UUID
// DEBUG: Log transition request details
ec.logger.info("=== MCP RPC TRANSITION START ===")
ec.logger.info("MCP RPC Request - ID: ${id}")
ec.logger.info("MCP RPC Request - Params: ${params}")
ec.logger.info("MCP RPC Request - User: ${ec.user.username}, UserID: ${ec.user.userId}")
ec.logger.info("MCP RPC Request - Current Time: ${ec.user.getNowTimestamp()}")
// Check MCP protocol version header
def protocolVersion = ec.web.request.getHeader("MCP-Protocol-Version")
if (!protocolVersion) {
protocolVersion = "2025-06-18" // Default to latest supported
}
ec.logger.info("MCP Protocol Version: ${protocolVersion}")
// Validate Content-Type header for POST requests
def contentType = ec.web?.request?.getContentType()
ec.logger.info("Validating Content-Type: ${contentType}")
if (!contentType?.contains("application/json")) {
ec.logger.warn("Invalid Content-Type: ${contentType}, expected application/json or application/json-rpc")
ec.web?.response?.setStatus(415) // Unsupported Media Type
ec.web?.response?.setContentType("text/plain")
ec.web?.response?.getWriter()?.write("Content-Type must be application/json or application/json-rpc for JSON-RPC messages")
ec.web?.response?.getWriter()?.flush()
return
}
ec.logger.info("Content-Type validation passed")
// Validate Accept header - prioritize application/json over text/event-stream when both present
def acceptHeader = ec.web?.request?.getHeader("Accept")
ec.logger.info("Validating Accept header: ${acceptHeader}")
def wantsStreaming = false
if (acceptHeader) {
if (acceptHeader.contains("text/event-stream") && !acceptHeader.contains("application/json")) {
wantsStreaming = true
}
// If both are present, prefer application/json (don't set wantsStreaming)
}
ec.logger.info("Client wants streaming: ${wantsStreaming} (Accept: ${acceptHeader})")
// Validate Origin header for DNS rebinding protection
def originHeader = ec.web?.request?.getHeader("Origin")
ec.logger.info("Checking Origin header: ${originHeader}")
if (originHeader) {
try {
def originValid = ec.service.sync("McpServices.validate#Origin", [origin: originHeader]).isValid
ec.logger.info("Origin validation result: ${originValid}")
if (!originValid) {
ec.logger.warn("Invalid Origin header rejected: ${originHeader}")
ec.web?.response?.setStatus(403) // Forbidden
ec.web?.response?.setContentType("text/plain")
ec.web?.response?.getWriter()?.write("Invalid Origin header")
ec.web?.response?.getWriter()?.flush()
return
}
} catch (Exception e) {
ec.logger.error("Error during Origin validation", e)
ec.web?.response?.setStatus(500) // Internal Server Error
ec.web?.response?.setContentType("text/plain")
ec.web?.response?.getWriter()?.write("Error during Origin validation: ${e.message}")
ec.web?.response?.getWriter()?.flush()
return
}
} else {
ec.logger.info("No Origin header present")
}
// Set protocol version header on all responses
ec.web?.response?.setHeader("MCP-Protocol-Version", protocolVersion)
ec.logger.info("Set MCP protocol version header")
// Handle session management
def sessionId = ec.web?.request?.getHeader("Mcp-Session-Id")
def isInitialize = (method == "initialize")
ec.logger.info("Session management - SessionId: ${sessionId}, IsInitialize: ${isInitialize}")
if (!isInitialize && !sessionId) {
ec.logger.warn("Missing session ID for non-initialization request")
ec.web?.response?.setStatus(400) // Bad Request
ec.web?.response?.setContentType("text/plain")
ec.web?.response?.getWriter()?.write("Mcp-Session-Id header required for non-initialization requests")
ec.web?.response?.getWriter()?.flush()
return
}
// Generate new session ID for initialization
if (isInitialize) {
def newSessionId = UUID.randomUUID().toString()
ec.web?.response?.setHeader("Mcp-Session-Id", newSessionId)
ec.logger.info("Generated new session ID: ${newSessionId}")
}
// Parse JSON-RPC request body if not already in parameters
if (!jsonrpc && !method) {
def requestBody = ec.web.request.getInputStream()?.getText()
if (requestBody) {
def jsonSlurper = new JsonSlurper()
def jsonRequest = jsonSlurper.parseText(requestBody)
jsonrpc = jsonRequest.jsonrpc
id = jsonRequest.id
method = jsonRequest.method
params = jsonRequest.params
}
}
// Validate JSON-RPC version
ec.logger.info("Validating JSON-RPC version: ${jsonrpc}")
if (jsonrpc && jsonrpc != "2.0") {
ec.logger.warn("Invalid JSON-RPC version: ${jsonrpc}")
def errorResponse = new JsonBuilder([
jsonrpc: "2.0",
error: [
code: -32600,
message: "Invalid Request: Only JSON-RPC 2.0 supported"
],
id: id
]).toString()
if (wantsStreaming) {
sendSseError(ec, errorResponse, protocolVersion)
} else {
ec.web.sendJsonResponse(errorResponse)
}
return
}
ec.logger.info("JSON-RPC version validation passed")
def result = null
def error = null
try {
// Route to appropriate MCP service using correct service names
def serviceName = null
ec.logger.info("Mapping method '${method}' to service name")
switch (method) {
case "mcp#Ping":
case "ping":
serviceName = "McpServices.mcp#Ping"
ec.logger.info("Mapped to service: ${serviceName}")
break
case "initialize":
case "mcp#Initialize":
serviceName = "McpServices.mcp#Initialize"
ec.logger.info("Mapped to service: ${serviceName}")
break
case "tools/list":
case "mcp#ToolsList":
serviceName = "McpServices.mcp#ToolsList"
ec.logger.info("Mapped to service: ${serviceName}")
break
case "tools/call":
case "mcp#ToolsCall":
serviceName = "McpServices.mcp#ToolsCall"
ec.logger.info("Mapped to service: ${serviceName}")
break
case "resources/list":
case "mcp#ResourcesList":
serviceName = "McpServices.mcp#ResourcesList"
ec.logger.info("Mapped to service: ${serviceName}")
break
case "resources/read":
case "mcp#ResourcesRead":
serviceName = "McpServices.mcp#ResourcesRead"
ec.logger.info("Mapped to service: ${serviceName}")
break
default:
ec.logger.warn("Unknown method: ${method}")
error = [
code: -32601,
message: "Method not found: ${method}"
]
}
if (serviceName && !error) {
ec.logger.info("Calling service: ${serviceName} with params: ${params}")
// Check if service exists before calling
if (!ec.service.isServiceDefined(serviceName)) {
ec.logger.error("Service not defined: ${serviceName}")
error = [
code: -32601,
message: "Service not found: ${serviceName}"
]
} else {
// Call the actual MCP service
def serviceStartTime = System.currentTimeMillis()
result = ec.service.sync().name(serviceName).parameters(params ?: [:]).call()
def serviceEndTime = System.currentTimeMillis()
ec.logger.info("Service ${serviceName} completed in ${serviceEndTime - serviceStartTime}ms")
ec.logger.info("Service result type: ${result?.getClass()?.getSimpleName()}")
ec.logger.info("Service result: ${result}")
}
}
} catch (Exception e) {
ec.logger.error("MCP request error for method ${method}", e)
ec.logger.error("Exception details: ${e.getClass().getName()}: ${e.message}")
ec.logger.error("Exception stack trace: ${e.getStackTrace()}")
error = [
code: -32603,
message: "Internal error: ${e.message}"
]
}
// Build JSON-RPC response
ec.logger.info("Building JSON-RPC response")
def responseObj = [
jsonrpc: "2.0",
id: id
]
if (error) {
responseObj.error = error
ec.logger.info("Response includes error: ${error}")
} else {
responseObj.result = result
ec.logger.info("Response includes result")
}
def jsonResponse = new JsonBuilder(responseObj).toString()
ec.logger.info("Built JSON response: ${jsonResponse}")
ec.logger.info("JSON response length: ${jsonResponse.length()}")
// Handle both JSON-RPC 2.0 and SSE responses
if (wantsStreaming) {
ec.logger.info("Creating SSE response")
sendSseResponse(ec, responseObj, protocolVersion)
} else {
ec.logger.info("Creating JSON-RPC 2.0 response")
ec.web.sendJsonResponse(jsonResponse)
ec.logger.info("Sent JSON-RPC response, length: ${jsonResponse.length()}")
}
ec.logger.info("=== MCP SCREEN REQUEST END ===")
]]></script>
</actions>
<default-response type="none"/>
</transition>
<actions>
<!-- SSE Helper Functions -->
<script><![CDATA[
def handleSseStream(ec, protocolVersion) {
// Set SSE headers
ec.web.response.setContentType("text/event-stream")
ec.web.response.setCharacterEncoding("UTF-8")
ec.web.response.setHeader("Cache-Control", "no-cache")
ec.web.response.setHeader("Connection", "keep-alive")
ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
def writer = ec.web.response.writer
try {
// Send initial connection event
writer.write("event: connected\n")
writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
writer.write("\n")
writer.flush()
// Keep connection alive with periodic pings
def count = 0
while (count < 30) { // Keep alive for ~30 seconds
Thread.sleep(1000)
writer.write("event: ping\n")
writer.write("data: {\"timestamp\":\"${ec.user.nowTimestamp}\"}\n")
writer.write("\n")
writer.flush()
count++
}
} catch (Exception e) {
ec.logger.warn("SSE stream interrupted: ${e.message}")
} finally {
writer.close()
}
}
def sendSseResponse(ec, responseObj, protocolVersion) {
// Set SSE headers
ec.web.response.setContentType("text/event-stream")
ec.web.response.setCharacterEncoding("UTF-8")
ec.web.response.setHeader("Cache-Control", "no-cache")
ec.web.response.setHeader("Connection", "keep-alive")
ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
def writer = ec.web.response.writer
def jsonBuilder = new JsonBuilder(responseObj)
try {
// Send response as SSE event
writer.write("event: response\n")
writer.write("data: ${jsonBuilder.toString()}\n")
writer.write("\n")
writer.flush()
} catch (Exception e) {
ec.logger.error("Error sending SSE response: ${e.message}")
} finally {
writer.close()
}
}
def sendSseError(ec, errorResponse, protocolVersion) {
// Set SSE headers
ec.web.response.setContentType("text/event-stream")
ec.web.response.setCharacterEncoding("UTF-8")
ec.web.response.setHeader("Cache-Control", "no-cache")
ec.web.response.setHeader("Connection", "keep-alive")
ec.web.response.setHeader("MCP-Protocol-Version", protocolVersion)
def writer = ec.web.response.writer
try {
// Send error as SSE event
writer.write("event: error\n")
writer.write("data: ${errorResponse}\n")
writer.write("\n")
writer.flush()
} catch (Exception e) {
ec.logger.error("Error sending SSE error: ${e.message}")
} finally {
writer.close()
}
}
]]></script>
</actions>
<widgets>
<!-- This screen should never render widgets - it handles JSON-RPC requests directly -->
</widgets>
</screen>
\ No newline at end of file
......@@ -257,10 +257,14 @@
def userId = ec.user.userId
def userAccountId = userId ? userId : null
// Build server capabilities
// Get user-specific tools and resources
def toolsResult = ec.service.sync().name("org.moqui.mcp.McpServices.mcp#ToolsList").parameters([:]).call()
def resourcesResult = ec.service.sync().name("org.moqui.mcp.McpServices.mcp#ResourcesList").parameters([:]).call()
// Build server capabilities based on what user can access
def serverCapabilities = [
tools: [:],
resources: [:],
tools: toolsResult?.result?.tools ? [listChanged: true] : [:],
resources: resourcesResult?.result?.resources ? [subscribe: true, listChanged: true] : [:],
logging: [:]
]
......@@ -274,8 +278,10 @@
protocolVersion: "2025-06-18",
capabilities: serverCapabilities,
serverInfo: serverInfo,
instructions: "This server provides access to Moqui ERP services and entities through MCP. Use tools/list to discover available operations."
instructions: "This server provides access to Moqui ERP services and entities through MCP. Tools and resources are filtered based on your permissions."
]
ec.logger.info("MCP Initialize for user ${userId}: ${toolsResult?.result?.tools?.size() ?: 0} tools, ${resourcesResult?.result?.resources?.size() ?: 0} resources")
]]></script>
</actions>
</service>
......@@ -321,13 +327,27 @@
]
]
// Add service metadata to help LLM
if (serviceInfo.verb && serviceInfo.noun) {
tool.description += " (${serviceInfo.verb}:${serviceInfo.noun})"
}
// Convert service parameters to JSON Schema
def inParamNames = serviceInfo.getInParameterNames()
for (paramName in inParamNames) {
def paramInfo = serviceInfo.getInParameter(paramName)
def paramDesc = paramInfo.description ?: ""
// Add type information to description for LLM
if (!paramDesc) {
paramDesc = "Parameter of type ${paramInfo.type}"
} else {
paramDesc += " (type: ${paramInfo.type})"
}
tool.inputSchema.properties[paramName] = [
type: convertMoquiTypeToJsonSchemaType(paramInfo.type),
description: paramInfo.description ?: ""
description: paramDesc
]
if (paramInfo.required) {
......@@ -474,10 +494,15 @@
def resource = [
uri: "entity://${entityName}",
name: entityName,
description: "Moqui entity: ${entityName}",
description: entityInfo.description ?: "Moqui entity: ${entityName}",
mimeType: "application/json"
]
// Add entity metadata to help LLM
if (entityInfo.packageName) {
resource.description += " (package: ${entityInfo.packageName})"
}
availableResources << resource
} catch (Exception e) {
......@@ -536,6 +561,9 @@
def startTime = System.currentTimeMillis()
try {
// Get entity definition for field descriptions
def entityDef = ec.entity.getEntityDefinition(entityName)
// Query entity data (limited to prevent large responses)
def entityList = ec.entity.find(entityName)
.limit(100)
......@@ -543,6 +571,18 @@
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Build field info for LLM
def fieldInfo = []
entityDef.allFieldInfoList.each { field ->
fieldInfo << [
name: field.name,
type: field.type,
description: field.description ?: "",
isPk: field.isPk,
required: field.notNull
]
}
// Convert to MCP resource content
def contents = [
[
......@@ -550,7 +590,10 @@
mimeType: "application/json",
text: new JsonBuilder([
entityName: entityName,
description: entityDef.description ?: "",
packageName: entityDef.packageName,
recordCount: entityList.size(),
fields: fieldInfo,
data: entityList
]).toString()
]
......
<?xml version="1.0" encoding="UTF-8"?>
<!-- This software is in the public domain under CC0 1.0 Universal plus a
Grant of Patent License.
To the extent possible under law, author(s) have dedicated all
copyright and related and neighboring rights to this software to the
public domain worldwide. This software is distributed without any warranty.
You should have received a copy of the CC0 Public Domain Dedication
along with this software (see the LICENSE.md file). If not, see
<https://creativecommons.org/publicdomain/zero/1.0/>. -->
<resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/rest-api-3.xsd"
name="mcp" displayName="MCP API" version="2.0.0"
description="MCP API services for Moqui integration - NOTE: Main functionality moved to screen/webapp.xml">
<!-- NOTE: Main MCP functionality moved to screen/webapp.xml for unified JSON-RPC and SSE handling -->
<!-- Keeping only basic GET endpoints for health checks and debugging -->
<resource name="rpc">
<method type="get">
<service name="McpServices.mcp#Ping"/>
</method>
<method type="get" path="debug">
<service name="McpServices.debug#ComponentStatus"/>
</method>
</resource>
</resource>
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package org.moqui.mcp
import groovy.json.JsonSlurper
import org.moqui.impl.context.ExecutionContextFactoryImpl
import org.moqui.context.ArtifactAuthorizationException
import org.moqui.context.ArtifactTarpitException
import org.moqui.impl.context.ExecutionContextImpl
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import javax.servlet.ServletConfig
import javax.servlet.ServletException
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.UUID
/**
* Enhanced MCP Servlet with proper SSE handling inspired by HttpServletSseServerTransportProvider
* This implementation provides better SSE support and session management.
*/
class EnhancedMcpServlet extends HttpServlet {
protected final static Logger logger = LoggerFactory.getLogger(EnhancedMcpServlet.class)
private JsonSlurper jsonSlurper = new JsonSlurper()
// Session management for SSE connections
private final Map<String, McpSession> sessions = new ConcurrentHashMap<>()
private final AtomicBoolean isClosing = new AtomicBoolean(false)
@Override
void init(ServletConfig config) throws ServletException {
super.init(config)
String webappName = config.getInitParameter("moqui-name") ?:
config.getServletContext().getInitParameter("moqui-name")
logger.info("EnhancedMcpServlet initialized for webapp ${webappName}")
}
@Override
void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
ExecutionContextFactoryImpl ecfi =
(ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory")
String webappName = getInitParameter("moqui-name") ?:
getServletContext().getInitParameter("moqui-name")
if (ecfi == null || webappName == null) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"System is initializing, try again soon.")
return
}
// Handle CORS
if (handleCors(request, response, webappName, ecfi)) return
long startTime = System.currentTimeMillis()
if (logger.traceEnabled) {
logger.trace("Start Enhanced MCP request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]")
}
ExecutionContextImpl activeEc = ecfi.activeContext.get()
if (activeEc != null) {
logger.warn("In EnhancedMcpServlet.service there is already an ExecutionContext for user ${activeEc.user.username}")
activeEc.destroy()
}
ExecutionContextImpl ec = ecfi.getEci()
try {
// Initialize web facade for authentication
ec.initWebFacade(webappName, request, response)
logger.info("Enhanced MCP Request authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}")
// If no user authenticated, try to authenticate as admin for MCP requests
if (!ec.user?.userId) {
logger.info("No user authenticated, attempting admin login for Enhanced MCP")
try {
ec.user.loginUser("admin", "admin")
logger.info("Enhanced MCP Admin login successful, user: ${ec.user?.username}")
} catch (Exception e) {
logger.warn("Enhanced MCP Admin login failed: ${e.message}")
}
}
// Route based on request method and path
String requestURI = request.getRequestURI()
String method = request.getMethod()
if ("GET".equals(method) && requestURI.endsWith("/sse")) {
handleSseConnection(request, response, ec)
} else if ("POST".equals(method) && requestURI.endsWith("/message")) {
handleMessage(request, response, ec)
} else {
// Fallback to JSON-RPC handling
handleJsonRpc(request, response, ec)
}
} catch (ArtifactAuthorizationException e) {
logger.warn("Enhanced MCP Access Forbidden (no authz): " + e.message)
response.setStatus(HttpServletResponse.SC_FORBIDDEN)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32001, message: "Access Forbidden: " + e.message],
id: null
]))
} catch (ArtifactTarpitException e) {
logger.warn("Enhanced MCP Too Many Requests (tarpit): " + e.message)
response.setStatus(429)
if (e.getRetryAfterSeconds()) {
response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
}
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32002, message: "Too Many Requests: " + e.message],
id: null
]))
} catch (Throwable t) {
logger.error("Error in Enhanced MCP request", t)
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32603, message: "Internal error: " + t.message],
id: null
]))
} finally {
ec.destroy()
}
}
private void handleSseConnection(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
throws IOException {
if (isClosing.get()) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down")
return
}
logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
// Set SSE headers
response.setContentType("text/event-stream")
response.setCharacterEncoding("UTF-8")
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
response.setHeader("Access-Control-Allow-Origin", "*")
String sessionId = UUID.randomUUID().toString()
// Create session transport
McpSession session = new McpSession(sessionId, response.writer)
sessions.put(sessionId, session)
try {
// Send initial connection event with endpoint info
sendSseEvent(response.writer, "endpoint", "/mcp-sse/message?sessionId=" + sessionId)
// Send initial resources list
def resourcesResult = processMcpMethod("resources/list", [:], ec)
sendSseEvent(response.writer, "resources", groovy.json.JsonOutput.toJson(resourcesResult))
// Send initial tools list
def toolsResult = processMcpMethod("tools/list", [:], ec)
sendSseEvent(response.writer, "tools", groovy.json.JsonOutput.toJson(toolsResult))
// Keep connection alive with periodic pings
int pingCount = 0
while (!response.isCommitted() && !isClosing.get() && pingCount < 60) { // 5 minutes max
Thread.sleep(5000) // Wait 5 seconds
if (!response.isCommitted() && !isClosing.get()) {
sendSseEvent(response.writer, "ping", groovy.json.JsonOutput.toJson([
type: "ping",
count: pingCount,
timestamp: System.currentTimeMillis()
]))
pingCount++
}
}
} catch (Exception e) {
logger.warn("Enhanced SSE connection interrupted: ${e.message}")
} finally {
// Clean up session
sessions.remove(sessionId)
try {
sendSseEvent(response.writer, "close", groovy.json.JsonOutput.toJson([
type: "disconnected",
timestamp: System.currentTimeMillis()
]))
} catch (Exception e) {
// Ignore errors during cleanup
}
}
}
private void handleMessage(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
throws IOException {
if (isClosing.get()) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down")
return
}
// Get session ID from request parameter
String sessionId = request.getParameter("sessionId")
if (sessionId == null) {
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.writer.write(groovy.json.JsonOutput.toJson([
error: "Session ID missing in message endpoint"
]))
return
}
// Get session from sessions map
McpSession session = sessions.get(sessionId)
if (session == null) {
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_NOT_FOUND)
response.writer.write(groovy.json.JsonOutput.toJson([
error: "Session not found: " + sessionId
]))
return
}
try {
// Read request body
BufferedReader reader = request.getReader()
StringBuilder body = new StringBuilder()
String line
while ((line = reader.readLine()) != null) {
body.append(line)
}
// Parse JSON-RPC message
def rpcRequest = jsonSlurper.parseText(body.toString())
// Process the method
def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec)
// Send response via SSE to the specific session
sendSseEvent(session.writer, "response", groovy.json.JsonOutput.toJson([
id: rpcRequest.id,
result: result
]))
response.setStatus(HttpServletResponse.SC_OK)
} catch (Exception e) {
logger.error("Error processing message: ${e.message}")
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
response.writer.write(groovy.json.JsonOutput.toJson([
error: e.message
]))
}
}
private void handleJsonRpc(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
throws IOException {
String method = request.getMethod()
String acceptHeader = request.getHeader("Accept")
String contentType = request.getContentType()
logger.info("Enhanced MCP JSON-RPC Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}")
// Handle POST requests for JSON-RPC
if (!"POST".equals(method)) {
response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET /mcp-sse/sse for SSE."],
id: null
]))
return
}
// Read and parse JSON-RPC request
String requestBody
try {
BufferedReader reader = request.getReader()
StringBuilder body = new StringBuilder()
String line
while ((line = reader.readLine()) != null) {
body.append(line)
}
requestBody = body.toString()
} catch (IOException e) {
logger.error("Failed to read request body: ${e.message}")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32700, message: "Failed to read request body: " + e.message],
id: null
]))
return
}
if (!requestBody) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32602, message: "Empty request body"],
id: null
]))
return
}
def rpcRequest
try {
rpcRequest = jsonSlurper.parseText(requestBody)
} catch (Exception e) {
logger.error("Failed to parse JSON-RPC request: ${e.message}")
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32700, message: "Invalid JSON: " + e.message],
id: null
]))
return
}
// Validate JSON-RPC 2.0 structure
if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
id: null
]))
return
}
// Process MCP method using Moqui services
def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec)
// Build JSON-RPC response
def rpcResponse = [
jsonrpc: "2.0",
id: rpcRequest.id,
result: result
]
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
}
private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec) {
logger.info("Enhanced METHOD: ${method} with params: ${params}")
switch (method) {
case "initialize":
return callMcpService("mcp#Initialize", params, ec)
case "ping":
return callMcpService("mcp#Ping", params, ec)
case "tools/list":
return callMcpService("mcp#ToolsList", params, ec)
case "tools/call":
return callMcpService("mcp#ToolsCall", params, ec)
case "resources/list":
return callMcpService("mcp#ResourcesList", params, ec)
case "resources/read":
return callMcpService("mcp#ResourcesRead", params, ec)
default:
throw new IllegalArgumentException("Unknown MCP method: ${method}")
}
}
private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) {
logger.info("Enhanced Calling MCP service: ${serviceName} with params: ${params}")
try {
def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}")
.parameters(params ?: [:])
.call()
logger.info("Enhanced MCP service ${serviceName} result: ${result}")
return result.result
} catch (Exception e) {
logger.error("Error calling Enhanced MCP service ${serviceName}", e)
throw e
}
}
private void sendSseEvent(PrintWriter writer, String eventType, String data) throws IOException {
writer.write("event: " + eventType + "\n")
writer.write("data: " + data + "\n\n")
writer.flush()
if (writer.checkError()) {
throw new IOException("Client disconnected")
}
}
// CORS handling based on MoquiServlet pattern
private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) {
String originHeader = request.getHeader("Origin")
if (originHeader) {
response.setHeader("Access-Control-Allow-Origin", originHeader)
response.setHeader("Access-Control-Allow-Credentials", "true")
}
String methodHeader = request.getHeader("Access-Control-Request-Method")
if (methodHeader) {
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
response.setHeader("Access-Control-Max-Age", "3600")
return true
}
return false
}
@Override
void destroy() {
logger.info("Destroying EnhancedMcpServlet")
isClosing.set(true)
// Close all active sessions
sessions.values().each { session ->
try {
session.close()
} catch (Exception e) {
logger.warn("Error closing session: ${e.message}")
}
}
sessions.clear()
super.destroy()
}
/**
* Simple session class for managing MCP SSE connections
*/
static class McpSession {
String sessionId
PrintWriter writer
Date createdAt
McpSession(String sessionId, PrintWriter writer) {
this.sessionId = sessionId
this.writer = writer
this.createdAt = new Date()
}
void close() {
// Session cleanup logic
}
}
}
\ No newline at end of file
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package org.moqui.mcp
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import javax.servlet.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class McpFilter implements Filter {
protected final static Logger logger = LoggerFactory.getLogger(McpFilter.class)
private MoquiMcpServlet mcpServlet = new MoquiMcpServlet()
@Override
void init(FilterConfig filterConfig) throws ServletException {
logger.info("========== MCP FILTER INITIALIZED ==========")
// Initialize the servlet with filter config
mcpServlet.init(new ServletConfig() {
@Override
String getServletName() { return "McpFilter" }
@Override
ServletContext getServletContext() { return filterConfig.getServletContext() }
@Override
String getInitParameter(String name) { return filterConfig.getInitParameter(name) }
@Override
Enumeration<String> getInitParameterNames() { return filterConfig.getInitParameterNames() }
})
}
@Override
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
logger.info("========== MCP FILTER DOFILTER CALLED ==========")
if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
HttpServletRequest httpRequest = (HttpServletRequest) request
HttpServletResponse httpResponse = (HttpServletResponse) response
// Check if this is an MCP request
String path = httpRequest.getRequestURI()
logger.info("========== MCP FILTER PATH: {} ==========", path)
if (path != null && path.contains("/mcpservlet")) {
logger.info("========== MCP FILTER HANDLING REQUEST ==========")
try {
// Handle MCP request directly, don't continue chain
mcpServlet.service(httpRequest, httpResponse)
return
} catch (Exception e) {
logger.error("Error in MCP filter", e)
// Send error response directly
httpResponse.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
httpResponse.setContentType("application/json")
httpResponse.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32603, message: "Internal error: " + e.message],
id: null
]))
return
}
}
}
// Not an MCP request, continue chain
chain.doFilter(request, response)
}
@Override
void destroy() {
mcpServlet.destroy()
logger.info("McpFilter destroyed")
}
}
\ No newline at end of file
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package org.moqui.mcp
import groovy.json.JsonSlurper
import org.moqui.impl.context.ExecutionContextFactoryImpl
import org.moqui.context.ArtifactAuthorizationException
import org.moqui.context.ArtifactTarpitException
import org.moqui.impl.context.ExecutionContextImpl
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import javax.servlet.ServletConfig
import javax.servlet.ServletException
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class MoquiMcpServlet extends HttpServlet {
protected final static Logger logger = LoggerFactory.getLogger(MoquiMcpServlet.class)
private JsonSlurper jsonSlurper = new JsonSlurper()
@Override
void init(ServletConfig config) throws ServletException {
super.init(config)
String webappName = config.getInitParameter("moqui-name") ?:
config.getServletContext().getInitParameter("moqui-name")
logger.info("MoquiMcpServlet initialized for webapp ${webappName}")
}
@Override
void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
ExecutionContextFactoryImpl ecfi =
(ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory")
String webappName = getInitParameter("moqui-name") ?:
getServletContext().getInitParameter("moqui-name")
if (ecfi == null || webappName == null) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"System is initializing, try again soon.")
return
}
// Handle CORS (following Moqui pattern)
if (handleCors(request, response, webappName, ecfi)) return
long startTime = System.currentTimeMillis()
if (logger.traceEnabled) {
logger.trace("Start MCP request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]")
}
ExecutionContextImpl activeEc = ecfi.activeContext.get()
if (activeEc != null) {
logger.warn("In MoquiMcpServlet.service there is already an ExecutionContext for user ${activeEc.user.username}")
activeEc.destroy()
}
ExecutionContextImpl ec = ecfi.getEci()
try {
// Initialize web facade for authentication but avoid screen system
ec.initWebFacade(webappName, request, response)
logger.info("MCP Request authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}")
// If no user authenticated, try to authenticate as admin for MCP requests
if (!ec.user?.userId) {
logger.info("No user authenticated, attempting admin login for MCP")
try {
ec.user.loginUser("admin", "admin")
logger.info("MCP Admin login successful, user: ${ec.user?.username}")
} catch (Exception e) {
logger.warn("MCP Admin login failed: ${e.message}")
}
}
// Handle MCP JSON-RPC protocol
handleMcpRequest(request, response, ec)
} catch (ArtifactAuthorizationException e) {
logger.warn("MCP Access Forbidden (no authz): " + e.message)
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(HttpServletResponse.SC_FORBIDDEN)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32001, message: "Access Forbidden: " + e.message],
id: null
]))
} catch (ArtifactTarpitException e) {
logger.warn("MCP Too Many Requests (tarpit): " + e.message)
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(429)
if (e.getRetryAfterSeconds()) {
response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
}
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32002, message: "Too Many Requests: " + e.message],
id: null
]))
} catch (Throwable t) {
logger.error("Error in MCP request", t)
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32603, message: "Internal error: " + t.message],
id: null
]))
} finally {
ec.destroy()
}
}
private void handleMcpRequest(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
throws IOException {
String method = request.getMethod()
String acceptHeader = request.getHeader("Accept")
String contentType = request.getContentType()
String userAgent = request.getHeader("User-Agent")
logger.info("MCP Request: ${method} ${request.requestURI} - Accept: ${acceptHeader}, Content-Type: ${contentType}, User-Agent: ${userAgent}")
// Handle SSE (Server-Sent Events) for streaming
if ("GET".equals(method) && acceptHeader != null && acceptHeader.contains("text/event-stream")) {
logger.info("Processing SSE request - GET with text/event-stream Accept header")
handleSseRequest(request, response, ec)
return
}
// Handle POST requests for JSON-RPC
if (!"POST".equals(method)) {
logger.warn("Rejecting non-POST request: ${method} - Only POST for JSON-RPC or GET with Accept: text/event-stream for SSE allowed")
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32601, message: "Method Not Allowed. Use POST for JSON-RPC or GET with Accept: text/event-stream for SSE."],
id: null
]))
return
}
// Read and parse JSON-RPC request following official MCP servlet pattern
logger.info("Processing JSON-RPC POST request")
String requestBody
try {
// Use BufferedReader pattern from official MCP servlet
BufferedReader reader = request.reader
StringBuilder body = new StringBuilder()
String line
while ((line = reader.readLine()) != null) {
body.append(line)
}
requestBody = body.toString()
logger.info("JSON-RPC request body (${requestBody.length()} chars): ${requestBody}")
} catch (IOException e) {
logger.error("Failed to read request body: ${e.message}")
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32700, message: "Failed to read request body: " + e.message],
id: null
]))
return
}
if (!requestBody) {
logger.warn("Empty request body in JSON-RPC POST request")
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32602, message: "Empty request body"],
id: null
]))
return
}
def rpcRequest
try {
rpcRequest = jsonSlurper.parseText(requestBody)
logger.info("Parsed JSON-RPC request: method=${rpcRequest.method}, id=${rpcRequest.id}")
} catch (Exception e) {
logger.error("Failed to parse JSON-RPC request: ${e.message}")
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32700, message: "Invalid JSON: " + e.message],
id: null
]))
return
}
// Validate JSON-RPC 2.0 basic structure
if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
logger.warn("Invalid JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}")
// Handle error directly without sendError to avoid Moqui error screen interference
response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson([
jsonrpc: "2.0",
error: [code: -32600, message: "Invalid JSON-RPC 2.0 request"],
id: null
]))
return
}
// Process MCP method
logger.info("Calling processMcpMethod with method: ${rpcRequest.method}, params: ${rpcRequest.params}")
def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec)
logger.info("processMcpMethod returned result: ${result}")
// Build JSON-RPC response
def rpcResponse = [
jsonrpc: "2.0",
id: rpcRequest.id,
result: result
]
logger.info("Sending JSON-RPC response: ${rpcResponse}")
// Send response following official MCP servlet pattern
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
}
private void handleSseRequest(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec)
throws IOException {
logger.info("Handling SSE request from ${request.remoteAddr}")
// Set SSE headers
response.setContentType("text/event-stream")
response.setCharacterEncoding("UTF-8")
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
// Send initial connection event
response.writer.write("event: connect\n")
response.writer.write("data: {\"type\":\"connected\",\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n")
response.writer.flush()
// Keep connection alive with periodic pings
long startTime = System.currentTimeMillis()
int pingCount = 0
try {
while (!response.isCommitted() && pingCount < 10) { // Limit to 10 pings for testing
Thread.sleep(5000) // Wait 5 seconds
if (!response.isCommitted()) {
response.writer.write("event: ping\n")
response.writer.write("data: {\"type\":\"ping\",\"count\":${pingCount},\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n")
response.writer.flush()
pingCount++
}
}
} catch (Exception e) {
logger.warn("SSE connection interrupted: ${e.message}")
} finally {
// Send close event
if (!response.isCommitted()) {
response.writer.write("event: close\n")
response.writer.write("data: {\"type\":\"disconnected\",\"timestamp\":\"${System.currentTimeMillis()}\"}\n\n")
response.writer.flush()
}
}
}
private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec) {
logger.info("METHOD: ${method} with params: ${params}")
switch (method) {
case "initialize":
return callMcpService("mcp#Initialize", params, ec)
case "ping":
return callMcpService("mcp#Ping", params, ec)
case "tools/list":
return callMcpService("mcp#ToolsList", params, ec)
case "tools/call":
return callMcpService("mcp#ToolsCall", params, ec)
case "resources/list":
return callMcpService("mcp#ResourcesList", params, ec)
case "resources/read":
return callMcpService("mcp#ResourcesRead", params, ec)
default:
throw new IllegalArgumentException("Unknown MCP method: ${method}")
}
}
private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) {
logger.info("Calling MCP service: ${serviceName} with params: ${params}")
try {
def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}")
.parameters(params ?: [:])
.call()
logger.info("MCP service ${serviceName} result: ${result}")
return result.result
} catch (Exception e) {
logger.error("Error calling MCP service ${serviceName}", e)
throw e
}
}
private Map<String, Object> initializeMcp(Map params, ExecutionContextImpl ec) {
logger.info("MCP Initialize called with params: ${params}")
// Discover available tools and resources
def toolsResult = listTools([:], ec)
def resourcesResult = listResources([:], ec)
def capabilities = [
tools: [:],
resources: [:],
logging: [:]
]
// Only include tools if we found any
if (toolsResult?.tools) {
capabilities.tools = [listChanged: true]
}
// Only include resources if we found any
if (resourcesResult?.resources) {
capabilities.resources = [subscribe: true, listChanged: true]
}
def initResult = [
protocolVersion: "2025-06-18",
capabilities: capabilities,
serverInfo: [
name: "Moqui MCP Server",
version: "2.0.0"
]
]
logger.info("MCP Initialize returning: ${initResult}")
return initResult
}
private Map<String, Object> pingMcp(Map params, ExecutionContextImpl ec) {
logger.info("MCP Ping called with params: ${params}")
return [
result: "pong"
]
}
private Map<String, Object> listTools(Map params, ExecutionContextImpl ec) {
// List available Moqui services as tools
def tools = []
// Entity services
tools << [
name: "EntityFind",
description: "Find entities in Moqui",
inputSchema: [
type: "object",
properties: [
entity: [type: "string", description: "Entity name"],
fields: [type: "array", description: "Fields to select"],
constraint: [type: "string", description: "Constraint expression"],
limit: [type: "number", description: "Maximum results"]
]
]
]
tools << [
name: "EntityCreate",
description: "Create entity records",
inputSchema: [
type: "object",
properties: [
entity: [type: "string", description: "Entity name"],
fields: [type: "object", description: "Field values"]
]
]
]
tools << [
name: "EntityUpdate",
description: "Update entity records",
inputSchema: [
type: "object",
properties: [
entity: [type: "string", description: "Entity name"],
fields: [type: "object", description: "Field values"],
constraint: [type: "string", description: "Constraint expression"]
]
]
]
tools << [
name: "EntityDelete",
description: "Delete entity records",
inputSchema: [
type: "object",
properties: [
entity: [type: "string", description: "Entity name"],
constraint: [type: "string", description: "Constraint expression"]
]
]
]
// Service execution tools
tools << [
name: "ServiceCall",
description: "Execute Moqui services",
inputSchema: [
type: "object",
properties: [
service: [type: "string", description: "Service name (verb:noun)"],
parameters: [type: "object", description: "Service parameters"]
]
]
]
// User management tools
tools << [
name: "UserFind",
description: "Find users in the system",
inputSchema: [
type: "object",
properties: [
username: [type: "string", description: "Username filter"],
email: [type: "string", description: "Email filter"],
enabled: [type: "boolean", description: "Filter by enabled status"]
]
]
]
// Party management tools
tools << [
name: "PartyFind",
description: "Find parties (organizations, persons)",
inputSchema: [
type: "object",
properties: [
partyType: [type: "string", description: "Party type (PERSON, ORGANIZATION)"],
partyName: [type: "string", description: "Party name filter"],
status: [type: "string", description: "Status filter"]
]
]
]
// Order management tools
tools << [
name: "OrderFind",
description: "Find sales orders",
inputSchema: [
type: "object",
properties: [
orderId: [type: "string", description: "Order ID"],
customerId: [type: "string", description: "Customer party ID"],
status: [type: "string", description: "Order status"],
fromDate: [type: "string", description: "From date (YYYY-MM-DD)"],
thruDate: [type: "string", description: "Thru date (YYYY-MM-DD)"]
]
]
]
// Product management tools
tools << [
name: "ProductFind",
description: "Find products",
inputSchema: [
type: "object",
properties: [
productId: [type: "string", description: "Product ID"],
productName: [type: "string", description: "Product name filter"],
productType: [type: "string", description: "Product type"],
category: [type: "string", description: "Product category"]
]
]
]
// Inventory tools
tools << [
name: "InventoryCheck",
description: "Check product inventory levels",
inputSchema: [
type: "object",
properties: [
productId: [type: "string", description: "Product ID"],
facilityId: [type: "string", description: "Facility ID"],
locationId: [type: "string", description: "Location ID"]
]
]
]
// System status tools
tools << [
name: "SystemStatus",
description: "Get system status and statistics",
inputSchema: [
type: "object",
properties: [
includeMetrics: [type: "boolean", description: "Include performance metrics"],
includeCache: [type: "boolean", description: "Include cache statistics"]
]
]
]
return [tools: tools]
}
private Map<String, Object> callTool(Map params, ExecutionContextImpl ec) {
String toolName = params.name as String
Map arguments = params.arguments as Map ?: [:]
logger.info("Calling tool via service: ${toolName} with arguments: ${arguments}")
try {
// Use the existing McpServices.mcp#ToolsCall service
def result = ec.service.sync().name("org.moqui.mcp.McpServices.mcp#ToolsCall")
.parameters([name: toolName, arguments: arguments])
.call()
logger.info("Tool call result: ${result}")
return result.result
} catch (Exception e) {
logger.error("Error calling tool ${toolName} via service", e)
return [
content: [[type: "text", text: "Error: " + e.message]],
isError: true
]
}
}
private Map<String, Object> callEntityFind(Map arguments, ExecutionContextImpl ec) {
String entity = arguments.entity as String
List<String> fields = arguments.fields as List<String>
String constraint = arguments.constraint as String
Integer limit = arguments.limit as Integer
def finder = ec.entity.find(entity).selectFields(fields ?: ["*"]).limit(limit ?: 100)
if (constraint) {
finder.condition(constraint)
}
def result = finder.list()
return [
content: [[type: "text", text: "Found ${result.size()} records: ${result}"]],
isError: false
]
}
private Map<String, Object> callEntityCreate(Map arguments, ExecutionContextImpl ec) {
String entity = arguments.entity as String
Map fields = arguments.fields as Map
def result = ec.entity.create(entity).setAll(fields).create()
return [
content: [[type: "text", text: "Created record: ${result}"]],
isError: false
]
}
private Map<String, Object> callEntityUpdate(Map arguments, ExecutionContextImpl ec) {
String entity = arguments.entity as String
Map fields = arguments.fields as Map
String constraint = arguments.constraint as String
def updater = ec.entity.update(entity).setAll(fields)
if (constraint) {
updater.condition(constraint)
}
int updated = updater.update()
return [
content: [[type: "text", text: "Updated ${updated} records"]],
isError: false
]
}
private Map<String, Object> callEntityDelete(Map arguments, ExecutionContextImpl ec) {
String entity = arguments.entity as String
String constraint = arguments.constraint as String
def deleter = ec.entity.delete(entity)
if (constraint) {
deleter.condition(constraint)
}
int deleted = deleter.delete()
return [
content: [[type: "text", text: "Deleted ${deleted} records"]],
isError: false
]
}
private Map<String, Object> callService(Map arguments, ExecutionContextImpl ec) {
String serviceName = arguments.service as String
Map parameters = arguments.parameters as Map ?: [:]
try {
def result = ec.service.sync().name(serviceName).parameters(parameters).call()
return [
content: [[type: "text", text: "Service ${serviceName} executed successfully. Result: ${result}"]],
isError: false
]
} catch (Exception e) {
return [
content: [[type: "text", text: "Error executing service ${serviceName}: ${e.message}"]],
isError: true
]
}
}
private Map<String, Object> callUserFind(Map arguments, ExecutionContextImpl ec) {
String username = arguments.username as String
String email = arguments.email as String
Boolean enabled = arguments.enabled as Boolean
def condition = new StringBuilder("1=1")
def parameters = [:]
if (username) {
condition.append(" AND username = :username")
parameters.username = username
}
if (email) {
condition.append(" AND email_address = :email")
parameters.email = email
}
if (enabled != null) {
condition.append(" AND enabled = :enabled")
parameters.enabled = enabled ? "Y" : "N"
}
def result = ec.entity.find("moqui.security.UserAccount")
.condition(condition.toString(), parameters)
.limit(50)
.list()
return [
content: [[type: "text", text: "Found ${result.size()} users: ${result.collect { [username: it.username, email: it.emailAddress, enabled: it.enabled] }}"]],
isError: false
]
}
private Map<String, Object> callPartyFind(Map arguments, ExecutionContextImpl ec) {
String partyType = arguments.partyType as String
String partyName = arguments.partyName as String
String status = arguments.status as String
def condition = new StringBuilder("1=1")
def parameters = [:]
if (partyType) {
condition.append(" AND party_type_id = :partyType")
parameters.partyType = partyType
}
if (partyName) {
condition.append(" AND (party_name ILIKE :partyName OR party_name ILIKE :partyName)")
parameters.partyName = "%${partyName}%"
}
if (status) {
condition.append(" AND status_id = :status")
parameters.status = status
}
def result = ec.entity.find("mantle.party.PartyAndName")
.condition(condition.toString(), parameters)
.limit(50)
.list()
return [
content: [[type: "text", text: "Found ${result.size()} parties: ${result.collect { [partyId: it.partyId, type: it.partyTypeId, name: it.partyName, status: it.statusId] }}"]],
isError: false
]
}
private Map<String, Object> callOrderFind(Map arguments, ExecutionContextImpl ec) {
String orderId = arguments.orderId as String
String customerId = arguments.customerId as String
String status = arguments.status as String
String fromDate = arguments.fromDate as String
String thruDate = arguments.thruDate as String
def condition = new StringBuilder("1=1")
def parameters = [:]
if (orderId) {
condition.append(" AND order_id = :orderId")
parameters.orderId = orderId
}
if (customerId) {
condition.append(" AND customer_party_id = :customerId")
parameters.customerId = customerId
}
if (status) {
condition.append(" AND status_id = :status")
parameters.status = status
}
if (fromDate) {
condition.append(" AND order_date >= :fromDate")
parameters.fromDate = fromDate
}
if (thruDate) {
condition.append(" AND order_date <= :thruDate")
parameters.thruDate = thruDate
}
def result = ec.entity.find("mantle.order.OrderHeader")
.condition(condition.toString(), parameters)
.limit(50)
.list()
return [
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] }}"]],
isError: false
]
}
private Map<String, Object> callProductFind(Map arguments, ExecutionContextImpl ec) {
String productId = arguments.productId as String
String productName = arguments.productName as String
String productType = arguments.productType as String
String category = arguments.category as String
def condition = new StringBuilder("1=1")
def parameters = [:]
if (productId) {
condition.append(" AND product_id = :productId")
parameters.productId = productId
}
if (productName) {
condition.append(" AND (product_name ILIKE :productName OR internal_name ILIKE :productName)")
parameters.productName = "%${productName}%"
}
if (productType) {
condition.append(" AND product_type_id = :productType")
parameters.productType = productType
}
if (category) {
condition.append(" AND primary_product_category_id = :category")
parameters.category = category
}
def result = ec.entity.find("mantle.product.Product")
.condition(condition.toString(), parameters)
.limit(50)
.list()
return [
content: [[type: "text", text: "Found ${result.size()} products: ${result.collect { [productId: it.productId, name: it.productName, type: it.productTypeId, category: it.primaryProductCategoryId] }}"]],
isError: false
]
}
private Map<String, Object> callInventoryCheck(Map arguments, ExecutionContextImpl ec) {
String productId = arguments.productId as String
String facilityId = arguments.facilityId as String
String locationId = arguments.locationId as String
if (!productId) {
return [
content: [[type: "text", text: "Error: productId is required"]],
isError: true
]
}
def condition = new StringBuilder("product_id = :productId")
def parameters = [productId: productId]
if (facilityId) {
condition.append(" AND facility_id = :facilityId")
parameters.facilityId = facilityId
}
if (locationId) {
condition.append(" AND location_id = :locationId")
parameters.locationId = locationId
}
def result = ec.entity.find("mantle.product.inventory.InventoryItem")
.condition(condition.toString(), parameters)
.list()
def totalAvailable = result.sum { it.availableToPromiseTotal ?: 0 }
def totalOnHand = result.sum { it.quantityOnHandTotal ?: 0 }
return [
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] }}"]],
isError: false
]
}
private Map<String, Object> callSystemStatus(Map arguments, ExecutionContextImpl ec) {
Boolean includeMetrics = arguments.includeMetrics as Boolean ?: false
Boolean includeCache = arguments.includeCache as Boolean ?: false
def status = [
serverTime: new Date(),
frameworkVersion: "3.1.0-rc2",
userCount: ec.entity.find("moqui.security.UserAccount").count(),
partyCount: ec.entity.find("mantle.party.Party").count(),
productCount: ec.entity.find("mantle.product.Product").count(),
orderCount: ec.entity.find("mantle.order.OrderHeader").count()
]
if (includeMetrics) {
status.memory = [
total: Runtime.getRuntime().totalMemory(),
free: Runtime.getRuntime().freeMemory(),
max: Runtime.getRuntime().maxMemory()
]
}
if (includeCache) {
def cacheFacade = ec.getCache()
status.cache = [
cacheNames: cacheFacade.getCacheNames(),
// Note: More detailed cache stats would require cache-specific API calls
]
}
return [
content: [[type: "text", text: "System Status: ${status}"]],
isError: false
]
}
private Map<String, Object> listResources(Map params, ExecutionContextImpl ec) {
// List available entities as resources
def resources = []
// Get all entity names
def entityNames = ec.entity.getEntityNames()
for (String entityName : entityNames) {
resources << [
uri: "entity://${entityName}",
name: entityName,
description: "Moqui Entity: ${entityName}",
mimeType: "application/json"
]
}
return [resources: resources]
}
private Map<String, Object> readResource(Map params, ExecutionContextImpl ec) {
String uri = params.uri as String
if (uri.startsWith("entity://")) {
String entityName = uri.substring(9) // Remove "entity://" prefix
try {
// Get entity definition
def entityDef = ec.entity.getEntityDefinition(entityName)
if (!entityDef) {
throw new IllegalArgumentException("Entity not found: ${entityName}")
}
// Get basic entity info
def entityInfo = [
name: entityName,
tableName: entityDef.tableName,
fields: entityDef.allFieldInfo.collect { [name: it.name, type: it.type] }
]
return [
contents: [[
uri: uri,
mimeType: "application/json",
text: groovy.json.JsonOutput.toJson(entityInfo)
]]
]
} catch (Exception e) {
throw new IllegalArgumentException("Error reading entity ${entityName}: " + e.message)
}
} else {
throw new IllegalArgumentException("Unsupported resource URI: ${uri}")
}
}
// CORS handling based on MoquiServlet pattern
private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) {
String originHeader = request.getHeader("Origin")
if (originHeader) {
response.setHeader("Access-Control-Allow-Origin", originHeader)
response.setHeader("Access-Control-Allow-Credentials", "true")
}
String methodHeader = request.getHeader("Access-Control-Request-Method")
if (methodHeader) {
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
response.setHeader("Access-Control-Max-Age", "3600")
return true
}
return false
}
}
\ No newline at end of file
/*
* This software is in the public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package org.moqui.mcp
import groovy.json.JsonSlurper
import org.moqui.impl.context.ExecutionContextFactoryImpl
import org.moqui.context.ArtifactAuthorizationException
import org.moqui.context.ArtifactTarpitException
import org.moqui.impl.context.ExecutionContextImpl
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import javax.servlet.AsyncContext
import javax.servlet.AsyncContextListener
import javax.servlet.AsyncEvent
import javax.servlet.ServletConfig
import javax.servlet.ServletException
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
/**
* Service-Based MCP Servlet that delegates all business logic to McpServices.xml.
*
* This servlet improves upon the original MoquiMcpServlet by:
* - Properly delegating to existing McpServices.xml instead of reimplementing logic
* - Adding SSE support for real-time bidirectional communication
* - Providing better session management and error handling
* - Supporting async operations for scalability
*/
class ServiceBasedMcpServlet extends HttpServlet {
protected final static Logger logger = LoggerFactory.getLogger(ServiceBasedMcpServlet.class)
private JsonSlurper jsonSlurper = new JsonSlurper()
// Session management for SSE connections
private final Map<String, AsyncContext> sseConnections = new ConcurrentHashMap<>()
private final Map<String, String> sessionClients = new ConcurrentHashMap<>()
// Executor for async operations and keep-alive pings
private ScheduledExecutorService executorService
// Configuration
private String sseEndpoint = "/sse"
private String messageEndpoint = "/mcp/message"
private int keepAliveIntervalSeconds = 30
private int maxConnections = 100
@Override
void init(ServletConfig config) throws ServletException {
super.init(config)
// Read configuration from servlet init parameters
sseEndpoint = config.getInitParameter("sseEndpoint") ?: sseEndpoint
messageEndpoint = config.getInitParameter("messageEndpoint") ?: messageEndpoint
keepAliveIntervalSeconds = config.getInitParameter("keepAliveIntervalSeconds")?.toInteger() ?: keepAliveIntervalSeconds
maxConnections = config.getInitParameter("maxConnections")?.toInteger() ?: maxConnections
// Initialize executor service
executorService = Executors.newScheduledThreadPool(4)
// Start keep-alive task
startKeepAliveTask()
String webappName = config.getInitParameter("moqui-name") ?:
config.getServletContext().getInitParameter("moqui-name")
logger.info("ServiceBasedMcpServlet initialized for webapp ${webappName}")
logger.info("SSE endpoint: ${sseEndpoint}, Message endpoint: ${messageEndpoint}")
logger.info("Keep-alive interval: ${keepAliveIntervalSeconds}s, Max connections: ${maxConnections}")
logger.info("All business logic delegated to McpServices.xml")
}
@Override
void destroy() {
super.destroy()
// Shutdown executor service
if (executorService) {
executorService.shutdown()
try {
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
executorService.shutdownNow()
}
} catch (InterruptedException e) {
executorService.shutdownNow()
Thread.currentThread().interrupt()
}
}
// Close all SSE connections
sseConnections.values().each { asyncContext ->
try {
asyncContext.complete()
} catch (Exception e) {
logger.warn("Error closing SSE connection: ${e.message}")
}
}
sseConnections.clear()
sessionClients.clear()
logger.info("ServiceBasedMcpServlet destroyed")
}
@Override
void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
ExecutionContextFactoryImpl ecfi =
(ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory")
String webappName = getInitParameter("moqui-name") ?:
getServletContext().getInitParameter("moqui-name")
if (ecfi == null || webappName == null) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"System is initializing, try again soon.")
return
}
// Handle CORS
if (handleCors(request, response, webappName, ecfi)) return
String pathInfo = request.getPathInfo()
// Route based on endpoint
if (pathInfo?.startsWith(sseEndpoint)) {
handleSseConnection(request, response, ecfi, webappName)
} else if (pathInfo?.startsWith(messageEndpoint)) {
handleMessage(request, response, ecfi, webappName)
} else {
// Legacy support for /rpc endpoint
if (pathInfo?.startsWith("/rpc")) {
handleLegacyRpc(request, response, ecfi, webappName)
} else {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "MCP endpoint not found")
}
}
}
private void handleSseConnection(HttpServletRequest request, HttpServletResponse response,
ExecutionContextFactoryImpl ecfi, String webappName)
throws IOException {
logger.info("New SSE connection request from ${request.remoteAddr}")
// Check connection limit
if (sseConnections.size() >= maxConnections) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE,
"Too many SSE connections")
return
}
// Set SSE headers
response.setContentType("text/event-stream")
response.setCharacterEncoding("UTF-8")
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")
response.setHeader("Pragma", "no-cache")
response.setHeader("Expires", "0")
response.setHeader("Connection", "keep-alive")
// Generate session ID
String sessionId = generateSessionId()
// Store client info
String userAgent = request.getHeader("User-Agent") ?: "Unknown"
sessionClients.put(sessionId, userAgent)
// Enable async support
AsyncContext asyncContext = request.startAsync(request, response)
asyncContext.setTimeout(0) // No timeout
sseConnections.put(sessionId, asyncContext)
logger.info("SSE connection established: ${sessionId} from ${userAgent}")
// Send initial connection event
sendSseEvent(sessionId, "connect", [
type: "connected",
sessionId: sessionId,
timestamp: System.currentTimeMillis(),
serverInfo: [
name: "Moqui Service-Based MCP Server",
version: "2.1.0",
protocolVersion: "2025-06-18",
endpoints: [
sse: sseEndpoint,
message: messageEndpoint
],
architecture: "Service-based - all business logic delegated to McpServices.xml"
]
])
// Set up connection close handling
asyncContext.addListener(new AsyncContextListener() {
@Override
void onComplete(AsyncEvent event) throws IOException {
sseConnections.remove(sessionId)
sessionClients.remove(sessionId)
logger.info("SSE connection completed: ${sessionId}")
}
@Override
void onTimeout(AsyncEvent event) throws IOException {
sseConnections.remove(sessionId)
sessionClients.remove(sessionId)
logger.info("SSE connection timeout: ${sessionId}")
}
@Override
void onError(AsyncEvent event) throws IOException {
sseConnections.remove(sessionId)
sessionClients.remove(sessionId)
logger.warn("SSE connection error: ${sessionId} - ${event.throwable?.message}")
}
@Override
void onStartAsync(AsyncEvent event) throws IOException {
// No action needed
}
})
}
private void handleMessage(HttpServletRequest request, HttpServletResponse response,
ExecutionContextFactoryImpl ecfi, String webappName)
throws IOException {
long startTime = System.currentTimeMillis()
if (logger.traceEnabled) {
logger.trace("Start MCP message request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]")
}
ExecutionContextImpl activeEc = ecfi.activeContext.get()
if (activeEc != null) {
logger.warn("In ServiceBasedMcpServlet.handleMessage there is already an ExecutionContext for user ${activeEc.user.username}")
activeEc.destroy()
}
ExecutionContextImpl ec = ecfi.getEci()
try {
// Initialize web facade for authentication
ec.initWebFacade(webappName, request, response)
logger.info("Service-Based MCP Message authenticated user: ${ec.user?.username}, userId: ${ec.user?.userId}")
// If no user authenticated, try to authenticate as admin for MCP requests
if (!ec.user?.userId) {
logger.info("No user authenticated, attempting admin login for Service-Based MCP")
try {
ec.user.loginUser("admin", "admin")
logger.info("Service-Based MCP Admin login successful, user: ${ec.user?.username}")
} catch (Exception e) {
logger.warn("Service-Based MCP Admin login failed: ${e.message}")
}
}
// Handle different HTTP methods
String method = request.getMethod()
if ("GET".equals(method)) {
// Handle SSE subscription or status check
handleGetMessage(request, response, ec)
} else if ("POST".equals(method)) {
// Handle JSON-RPC message
handlePostMessage(request, response, ec)
} else {
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED,
"Method not allowed. Use GET for SSE subscription or POST for JSON-RPC messages.")
}
} catch (ArtifactAuthorizationException e) {
logger.warn("Service-Based MCP Access Forbidden (no authz): " + e.message)
sendJsonRpcError(response, -32001, "Access Forbidden: " + e.message, null)
} catch (ArtifactTarpitException e) {
logger.warn("Service-Based MCP Too Many Requests (tarpit): " + e.message)
response.setStatus(429)
if (e.getRetryAfterSeconds()) {
response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
}
sendJsonRpcError(response, -32002, "Too Many Requests: " + e.message, null)
} catch (Throwable t) {
logger.error("Error in Service-Based MCP message request", t)
sendJsonRpcError(response, -32603, "Internal error: " + t.message, null)
} finally {
ec.destroy()
}
}
private void handleGetMessage(HttpServletRequest request, HttpServletResponse response,
ExecutionContextImpl ec) throws IOException {
String sessionId = request.getParameter("sessionId")
String acceptHeader = request.getHeader("Accept")
// If client wants SSE and has sessionId, this is a subscription request
if (acceptHeader?.contains("text/event-stream") && sessionId) {
if (sseConnections.containsKey(sessionId)) {
response.setContentType("text/event-stream")
response.setCharacterEncoding("UTF-8")
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
// Send subscription confirmation
response.writer.write("event: subscribed\n")
response.writer.write("data: {\"type\":\"subscribed\",\"sessionId\":\"${sessionId}\",\"timestamp\":\"${System.currentTimeMillis()}\",\"architecture\":\"Service-based\"}\n\n")
response.writer.flush()
} else {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Session not found")
}
} else {
// Return server status
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
def status = [
serverInfo: [
name: "Moqui Service-Based MCP Server",
version: "2.1.0",
protocolVersion: "2025-06-18",
architecture: "Service-based - all business logic delegated to McpServices.xml"
],
connections: [
active: sseConnections.size(),
max: maxConnections
],
endpoints: [
sse: sseEndpoint,
message: messageEndpoint,
rpc: "/rpc"
],
capabilities: [
tools: true,
resources: true,
prompts: true,
sse: true,
jsonRpc: true,
services: "McpServices.xml"
]
]
response.writer.write(groovy.json.JsonOutput.toJson(status))
}
}
private void handlePostMessage(HttpServletRequest request, HttpServletResponse response,
ExecutionContextImpl ec) throws IOException {
// Read and parse JSON-RPC request
String requestBody
try {
BufferedReader reader = request.reader
StringBuilder body = new StringBuilder()
String line
while ((line = reader.readLine()) != null) {
body.append(line)
}
requestBody = body.toString()
} catch (IOException e) {
logger.error("Failed to read request body: ${e.message}")
sendJsonRpcError(response, -32700, "Failed to read request body: " + e.message, null)
return
}
if (!requestBody) {
logger.warn("Empty request body in JSON-RPC POST request")
sendJsonRpcError(response, -32602, "Empty request body", null)
return
}
def rpcRequest
try {
rpcRequest = jsonSlurper.parseText(requestBody)
} catch (Exception e) {
logger.error("Failed to parse JSON-RPC request: ${e.message}")
sendJsonRpcError(response, -32700, "Invalid JSON: " + e.message, null)
return
}
// Validate JSON-RPC 2.0 basic structure
if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
logger.warn("Invalid JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}")
sendJsonRpcError(response, -32600, "Invalid JSON-RPC 2.0 request", rpcRequest?.id)
return
}
// Process MCP method by delegating to services
def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, rpcRequest)
// Build JSON-RPC response
def rpcResponse = [
jsonrpc: "2.0",
id: rpcRequest.id,
result: result
]
// Send response
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
}
private void handleLegacyRpc(HttpServletRequest request, HttpServletResponse response,
ExecutionContextFactoryImpl ecfi, String webappName)
throws IOException {
// Legacy support - delegate to existing MoquiMcpServlet logic
logger.info("Handling legacy RPC request - redirecting to services")
// For legacy requests, we can use the same service-based approach
ExecutionContextImpl activeEc = ecfi.activeContext.get()
if (activeEc != null) {
logger.warn("In ServiceBasedMcpServlet.handleLegacyRpc there is already an ExecutionContext for user ${activeEc.user.username}")
activeEc.destroy()
}
ExecutionContextImpl ec = ecfi.getEci()
try {
// Initialize web facade for authentication
ec.initWebFacade(webappName, request, response)
// If no user authenticated, try to authenticate as admin for MCP requests
if (!ec.user?.userId) {
logger.info("No user authenticated, attempting admin login for Legacy MCP")
try {
ec.user.loginUser("admin", "admin")
logger.info("Legacy MCP Admin login successful, user: ${ec.user?.username}")
} catch (Exception e) {
logger.warn("Legacy MCP Admin login failed: ${e.message}")
}
}
// Read and parse JSON-RPC request (same as POST handling)
String requestBody
try {
BufferedReader reader = request.reader
StringBuilder body = new StringBuilder()
String line
while ((line = reader.readLine()) != null) {
body.append(line)
}
requestBody = body.toString()
} catch (IOException e) {
logger.error("Failed to read legacy RPC request body: ${e.message}")
sendJsonRpcError(response, -32700, "Failed to read request body: " + e.message, null)
return
}
if (!requestBody) {
logger.warn("Empty request body in legacy RPC POST request")
sendJsonRpcError(response, -32602, "Empty request body", null)
return
}
def rpcRequest
try {
rpcRequest = jsonSlurper.parseText(requestBody)
} catch (Exception e) {
logger.error("Failed to parse legacy JSON-RPC request: ${e.message}")
sendJsonRpcError(response, -32700, "Invalid JSON: " + e.message, null)
return
}
// Validate JSON-RPC 2.0 basic structure
if (!rpcRequest?.jsonrpc || rpcRequest.jsonrpc != "2.0" || !rpcRequest?.method) {
logger.warn("Invalid legacy JSON-RPC 2.0 structure: jsonrpc=${rpcRequest?.jsonrpc}, method=${rpcRequest?.method}")
sendJsonRpcError(response, -32600, "Invalid JSON-RPC 2.0 request", rpcRequest?.id)
return
}
// Process MCP method by delegating to services
def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, rpcRequest)
// Build JSON-RPC response
def rpcResponse = [
jsonrpc: "2.0",
id: rpcRequest.id,
result: result
]
// Send response
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse))
} catch (ArtifactAuthorizationException e) {
logger.warn("Legacy MCP Access Forbidden (no authz): " + e.message)
sendJsonRpcError(response, -32001, "Access Forbidden: " + e.message, null)
} catch (ArtifactTarpitException e) {
logger.warn("Legacy MCP Too Many Requests (tarpit): " + e.message)
response.setStatus(429)
if (e.getRetryAfterSeconds()) {
response.addIntHeader("Retry-After", e.getRetryAfterSeconds())
}
sendJsonRpcError(response, -32002, "Too Many Requests: " + e.message, null)
} catch (Throwable t) {
logger.error("Error in legacy MCP message request", t)
sendJsonRpcError(response, -32603, "Internal error: " + t.message, null)
} finally {
ec.destroy()
}
}
private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, def rpcRequest) {
logger.info("Service-Based METHOD: ${method} with params: ${params}")
try {
switch (method) {
case "initialize":
return callMcpService("mcp#Initialize", params, ec)
case "ping":
return callMcpService("mcp#Ping", params, ec)
case "tools/list":
return callMcpService("mcp#ToolsList", params, ec)
case "tools/call":
return callMcpService("mcp#ToolsCall", params, ec)
case "resources/list":
return callMcpService("mcp#ResourcesList", params, ec)
case "resources/read":
return callMcpService("mcp#ResourcesRead", params, ec)
case "notifications/subscribe":
return handleSubscription(params, ec, rpcRequest)
default:
throw new IllegalArgumentException("Unknown MCP method: ${method}")
}
} catch (Exception e) {
logger.error("Error processing Service-Based MCP method ${method}", e)
throw e
}
}
private Map<String, Object> callMcpService(String serviceName, Map params, ExecutionContextImpl ec) {
logger.info("Service-Based Calling MCP service: ${serviceName} with params: ${params}")
try {
def result = ec.service.sync().name("org.moqui.mcp.McpServices.${serviceName}")
.parameters(params ?: [:])
.call()
logger.info("Service-Based MCP service ${serviceName} result: ${result}")
return result.result
} catch (Exception e) {
logger.error("Error calling Service-Based MCP service ${serviceName}", e)
throw e
}
}
private Map<String, Object> handleSubscription(Map params, ExecutionContextImpl ec, def rpcRequest) {
String sessionId = params.sessionId as String
String eventType = params.eventType as String
logger.info("Service-Based Subscription request: sessionId=${sessionId}, eventType=${eventType}")
if (!sessionId || !sseConnections.containsKey(sessionId)) {
throw new IllegalArgumentException("Invalid or expired session")
}
// Store subscription (in a real implementation, you'd maintain subscription lists)
// For now, just confirm subscription
// Send subscription confirmation via SSE
sendSseEvent(sessionId, "subscribed", [
type: "subscription_confirmed",
sessionId: sessionId,
eventType: eventType,
timestamp: System.currentTimeMillis(),
architecture: "Service-based via McpServices.xml"
])
return [
subscribed: true,
sessionId: sessionId,
eventType: eventType,
timestamp: System.currentTimeMillis()
]
}
private void sendJsonRpcError(HttpServletResponse response, int code, String message, Object id) throws IOException {
response.setStatus(HttpServletResponse.SC_OK)
response.setContentType("application/json")
response.setCharacterEncoding("UTF-8")
def errorResponse = [
jsonrpc: "2.0",
error: [code: code, message: message],
id: id
]
response.writer.write(groovy.json.JsonOutput.toJson(errorResponse))
}
private void sendSseEvent(String sessionId, String eventType, Map data) {
AsyncContext asyncContext = sseConnections.get(sessionId)
if (!asyncContext) {
logger.debug("SSE connection not found for session: ${sessionId}")
return
}
try {
HttpServletResponse response = asyncContext.getResponse()
response.writer.write("event: ${eventType}\n")
response.writer.write("data: ${groovy.json.JsonOutput.toJson(data)}\n\n")
response.writer.flush()
} catch (Exception e) {
logger.warn("Failed to send SSE event to ${sessionId}: ${e.message}")
// Remove broken connection
sseConnections.remove(sessionId)
sessionClients.remove(sessionId)
}
}
private void broadcastSseEvent(String eventType, Map data) {
sseConnections.keySet().each { sessionId ->
sendSseEvent(sessionId, eventType, data)
}
}
private void startKeepAliveTask() {
executorService.scheduleWithFixedDelay({
try {
sseConnections.keySet().each { sessionId ->
sendSseEvent(sessionId, "ping", [
type: "ping",
timestamp: System.currentTimeMillis(),
connections: sseConnections.size(),
architecture: "Service-based via McpServices.xml"
])
}
} catch (Exception e) {
logger.warn("Error in Service-Based keep-alive task: ${e.message}")
}
}, keepAliveIntervalSeconds, keepAliveIntervalSeconds, TimeUnit.SECONDS)
}
private String generateSessionId() {
return UUID.randomUUID().toString()
}
// CORS handling based on MoquiServlet pattern
private static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) {
String originHeader = request.getHeader("Origin")
if (originHeader) {
response.setHeader("Access-Control-Allow-Origin", originHeader)
response.setHeader("Access-Control-Allow-Credentials", "true")
}
String methodHeader = request.getHeader("Access-Control-Request-Method")
if (methodHeader) {
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Accept")
response.setHeader("Access-Control-Max-Age", "3600")
return true
}
return false
}
}
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<!--
This software is in the public domain under CC0 1.0 Universal plus a
Grant of Patent License.
To the extent possible under law, author(s) have dedicated all
copyright and related and neighboring rights to this software to the
public domain worldwide. This software is distributed without any
warranty.
You should have received a copy of the CC0 Public Domain Dedication
along with this software (see the LICENSE.md file). If not, see
<http://creativecommons.org/publicdomain/zero/1.0/>.
-->
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- Service-Based MCP Servlet Configuration -->
<servlet>
<servlet-name>ServiceBasedMcpServlet</servlet-name>
<servlet-class>org.moqui.mcp.ServiceBasedMcpServlet</servlet-class>
<!-- Configuration Parameters -->
<init-param>
<param-name>sseEndpoint</param-name>
<param-value>/sse</param-value>
</init-param>
<init-param>
<param-name>messageEndpoint</param-name>
<param-value>/mcp/message</param-value>
</init-param>
<init-param>
<param-name>keepAliveIntervalSeconds</param-name>
<param-value>30</param-value>
</init-param>
<init-param>
<param-name>maxConnections</param-name>
<param-value>100</param-value>
</init-param>
<!-- Enable async support for SSE -->
<async-supported>true</async-supported>
<!-- Load on startup -->
<load-on-startup>5</load-on-startup>
</servlet>
<!-- Servlet Mappings -->
<servlet-mapping>
<servlet-name>ServiceBasedMcpServlet</servlet-name>
<url-pattern>/sse/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>ServiceBasedMcpServlet</servlet-name>
<url-pattern>/mcp/message/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>ServiceBasedMcpServlet</servlet-name>
<url-pattern>/rpc/*</url-pattern>
</servlet-mapping>
<!-- Session Configuration -->
<session-config>
<session-timeout>30</session-timeout>
<cookie-config>
<http-only>true</http-only>
<secure>false</secure>
</cookie-config>
</session-config>
<!-- Security Constraints (optional - uncomment if needed) -->
<!--
<security-constraint>
<web-resource-collection>
<web-resource-name>MCP Endpoints</web-resource-name>
<url-pattern>/sse/*</url-pattern>
<url-pattern>/mcp/message/*</url-pattern>
<url-pattern>/rpc/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>admin</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>BASIC</auth-method>
<realm-name>Moqui MCP</realm-name>
</login-config>
-->
<!-- MIME Type Mappings -->
<mime-mapping>
<extension>json</extension>
<mime-type>application/json</mime-type>
</mime-mapping>
<!-- Default Welcome Files -->
<welcome-file-list>
<welcome-file>index.html</welcome-file>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
</web-app>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- MCP SDK Services Servlet Configuration -->
<servlet>
<servlet-name>McpSdkServicesServlet</servlet-name>
<servlet-class>org.moqui.mcp.McpSdkServicesServlet</servlet-class>
<!-- Configuration parameters -->
<init-param>
<param-name>moqui-name</param-name>
<param-value>moqui-mcp-2</param-value>
</init-param>
<!-- Enable async support (required for SSE) -->
<async-supported>true</async-supported>
<!-- Load on startup to initialize MCP server -->
<load-on-startup>1</load-on-startup>
</servlet>
<!-- Servlet mappings for MCP SDK Services endpoints -->
<!-- The MCP SDK will handle both SSE and message endpoints through this servlet -->
<servlet-mapping>
<servlet-name>McpSdkServicesServlet</servlet-name>
<url-pattern>/mcp/*</url-pattern>
</servlet-mapping>
<!-- Session configuration -->
<session-config>
<session-timeout>30</session-timeout>
<cookie-config>
<http-only>true</http-only>
<secure>false</secure>
</cookie-config>
</session-config>
</web-app>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- MCP SDK SSE Servlet Configuration -->
<servlet>
<servlet-name>McpSdkSseServlet</servlet-name>
<servlet-class>org.moqui.mcp.McpSdkSseServlet</servlet-class>
<!-- Configuration parameters -->
<init-param>
<param-name>moqui-name</param-name>
<param-value>moqui-mcp-2</param-value>
</init-param>
<!-- Enable async support (required for SSE) -->
<async-supported>true</async-supported>
<!-- Load on startup to initialize MCP server -->
<load-on-startup>1</load-on-startup>
</servlet>
<!-- Servlet mappings for MCP SDK SSE endpoints -->
<!-- The MCP SDK will handle both SSE and message endpoints through this servlet -->
<servlet-mapping>
<servlet-name>McpSdkSseServlet</servlet-name>
<url-pattern>/mcp/*</url-pattern>
</servlet-mapping>
<!-- Session configuration -->
<session-config>
<session-timeout>30</session-timeout>
<cookie-config>
<http-only>true</http-only>
<secure>false</secure>
</cookie-config>
</session-config>
</web-app>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- MCP SSE Servlet Configuration -->
<servlet>
<servlet-name>McpSseServlet</servlet-name>
<servlet-class>org.moqui.mcp.McpSseServlet</servlet-class>
<!-- Configuration parameters -->
<init-param>
<param-name>moqui-name</param-name>
<param-value>moqui-mcp-2</param-value>
</init-param>
<init-param>
<param-name>sseEndpoint</param-name>
<param-value>/sse</param-value>
</init-param>
<init-param>
<param-name>messageEndpoint</param-name>
<param-value>/mcp/message</param-value>
</init-param>
<init-param>
<param-name>keepAliveIntervalSeconds</param-name>
<param-value>30</param-value>
</init-param>
<init-param>
<param-name>maxConnections</param-name>
<param-value>100</param-value>
</init-param>
<!-- Enable async support -->
<async-supported>true</async-supported>
</servlet>
<!-- Servlet mappings for MCP SSE endpoints -->
<servlet-mapping>
<servlet-name>McpSseServlet</servlet-name>
<url-pattern>/sse/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>McpSseServlet</servlet-name>
<url-pattern>/mcp/message/*</url-pattern>
</servlet-mapping>
<!-- Session configuration -->
<session-config>
<session-timeout>30</session-timeout>
<cookie-config>
<http-only>true</http-only>
<secure>false</secure>
</cookie-config>
</session-config>
</web-app>
\ No newline at end of file