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