MVP: Simplify MCP to JSON-RPC 2.0 only
- Remove SSE streaming support for MVP simplicity - Force JSON-RPC 2.0 responses regardless of Accept header - Simplify REST configuration to only support application/json - Clean up duplicate Accept header validation - Remove streaming response logic and headers This enables opencode connection without SSE complexity while preserving full MCP protocol functionality.
Showing
3 changed files
with
278 additions
and
888 deletions
service/McpJsonRpcServices.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, 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 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 | <services xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| 14 | xsi:noNamespaceSchemaLocation="http://moqui.org/xsd/service-definition-3.xsd"> | ||
| 15 | |||
| 16 | <!-- MCP JSON-RPC 2.0 Handler --> | ||
| 17 | |||
| 18 | <service verb="handle" noun="JsonRpcRequest" authenticate="true" allow-remote="true" transaction-timeout="300"> | ||
| 19 | <description>Handle MCP JSON-RPC 2.0 requests with direct Moqui integration (MCP 2025-06-18 compliant)</description> | ||
| 20 | <in-parameters> | ||
| 21 | <parameter name="jsonrpc" required="true"/> | ||
| 22 | <parameter name="id"/> | ||
| 23 | <parameter name="method"/> | ||
| 24 | <parameter name="params" type="Map"/> | ||
| 25 | </in-parameters> | ||
| 26 | <out-parameters> | ||
| 27 | <parameter name="response" type="text-very-long"/> | ||
| 28 | </out-parameters> | ||
| 29 | <actions> | ||
| 30 | <script><![CDATA[ | ||
| 31 | import org.moqui.context.ExecutionContext | ||
| 32 | import groovy.json.JsonBuilder | ||
| 33 | import java.util.UUID | ||
| 34 | |||
| 35 | ExecutionContext ec = context.ec | ||
| 36 | |||
| 37 | // Validate HTTP method - only POST allowed for JSON-RPC messages | ||
| 38 | def httpMethod = ec.web?.request?.method | ||
| 39 | if (httpMethod != "POST") { | ||
| 40 | ec.web?.response?.setStatus(405) // Method Not Allowed | ||
| 41 | ec.web?.response?.setHeader("Allow", "POST") | ||
| 42 | response = "Method Not Allowed. Use POST for JSON-RPC messages." | ||
| 43 | return | ||
| 44 | } | ||
| 45 | |||
| 46 | // Validate Accept header - must include both application/json and text/event-stream | ||
| 47 | def acceptHeader = ec.web?.request?.getHeader("Accept") | ||
| 48 | if (!acceptHeader?.contains("application/json") || !acceptHeader?.contains("text/event-stream")) { | ||
| 49 | ec.web?.response?.setStatus(406) // Not Acceptable | ||
| 50 | response = "Accept header must include both application/json and text/event-stream" | ||
| 51 | return | ||
| 52 | } | ||
| 53 | |||
| 54 | // Validate Origin header for DNS rebinding protection | ||
| 55 | def originHeader = ec.web?.request?.getHeader("Origin") | ||
| 56 | if (originHeader) { | ||
| 57 | def originValid = ec.service.sync("mo-mcp.McpJsonRpcServices.isValidOrigin#Helper", [origin: originHeader, ec: ec]).isValid | ||
| 58 | if (!originValid) { | ||
| 59 | ec.web?.response?.setStatus(403) // Forbidden | ||
| 60 | response = "Invalid Origin header" | ||
| 61 | return | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | // Check if client wants streaming by looking at Accept header | ||
| 66 | def wantsStreaming = acceptHeader?.contains("text/event-stream") | ||
| 67 | |||
| 68 | // Detect request type: request (has method and id), notification (has method, no id), response (no method) | ||
| 69 | def isNotification = (method != null && (id == null || id == "")) | ||
| 70 | def isResponse = (method == null) | ||
| 71 | def isRequest = (method != null && id != null && id != "") | ||
| 72 | |||
| 73 | // Set protocol version header on all responses | ||
| 74 | ec.web?.response?.setHeader("MCP-Protocol-Version", "2025-06-18") | ||
| 75 | |||
| 76 | // Handle session management | ||
| 77 | def sessionId = ec.web?.request?.getHeader("Mcp-Session-Id") | ||
| 78 | def isInitialize = (method == "initialize") | ||
| 79 | |||
| 80 | if (!isInitialize && !sessionId) { | ||
| 81 | ec.web?.response?.setStatus(400) // Bad Request | ||
| 82 | response = "Mcp-Session-Id header required for non-initialization requests" | ||
| 83 | return | ||
| 84 | } | ||
| 85 | |||
| 86 | // Validate JSON-RPC version | ||
| 87 | if (jsonrpc != "2.0") { | ||
| 88 | def errorResponse = new JsonBuilder([ | ||
| 89 | jsonrpc: "2.0", | ||
| 90 | error: [ | ||
| 91 | code: -32600, | ||
| 92 | message: "Invalid Request: Only JSON-RPC 2.0 supported" | ||
| 93 | ], | ||
| 94 | id: id | ||
| 95 | ]).toString() | ||
| 96 | |||
| 97 | if (wantsStreaming) { | ||
| 98 | response = "event: error\nid: ${UUID.randomUUID().toString()}\ndata: ${errorResponse}\n\n" | ||
| 99 | ec.web?.response?.setContentType("text/event-stream") | ||
| 100 | ec.web?.response?.setHeader("Cache-Control", "no-cache") | ||
| 101 | ec.web?.response?.setHeader("Connection", "keep-alive") | ||
| 102 | } else { | ||
| 103 | ec.web?.response?.setStatus(400) // Bad Request | ||
| 104 | response = errorResponse | ||
| 105 | } | ||
| 106 | return | ||
| 107 | } | ||
| 108 | |||
| 109 | def result = null | ||
| 110 | def error = null | ||
| 111 | def newSessionId = null | ||
| 112 | |||
| 113 | try { | ||
| 114 | // Route to appropriate MCP method handler | ||
| 115 | switch (method) { | ||
| 116 | case "initialize": | ||
| 117 | result = handleInitialize(params, ec) | ||
| 118 | // Generate new session ID for initialization | ||
| 119 | newSessionId = UUID.randomUUID().toString() | ||
| 120 | ec.web?.response?.setHeader("Mcp-Session-Id", newSessionId) | ||
| 121 | break | ||
| 122 | case "tools/list": | ||
| 123 | result = handleToolsList(params, ec) | ||
| 124 | break | ||
| 125 | case "tools/call": | ||
| 126 | result = handleToolsCall(params, ec, wantsStreaming) | ||
| 127 | break | ||
| 128 | case "resources/list": | ||
| 129 | result = handleResourcesList(params, ec) | ||
| 130 | break | ||
| 131 | case "resources/read": | ||
| 132 | result = handleResourcesRead(params, ec, wantsStreaming) | ||
| 133 | break | ||
| 134 | case "ping": | ||
| 135 | result = handlePing(params, ec) | ||
| 136 | break | ||
| 137 | default: | ||
| 138 | if (method) { | ||
| 139 | error = [ | ||
| 140 | code: -32601, | ||
| 141 | message: "Method not found: ${method}" | ||
| 142 | ] | ||
| 143 | } else { | ||
| 144 | // This is a response from client, just acknowledge | ||
| 145 | ec.web?.response?.setStatus(202) // Accepted | ||
| 146 | response = "" | ||
| 147 | return | ||
| 148 | } | ||
| 149 | } | ||
| 150 | } catch (Exception e) { | ||
| 151 | ec.logger.error("MCP JSON-RPC error for method ${method}", e) | ||
| 152 | error = [ | ||
| 153 | code: -32603, | ||
| 154 | message: "Internal error: ${e.message}" | ||
| 155 | ] | ||
| 156 | } | ||
| 157 | |||
| 158 | // Handle different request types according to MCP spec | ||
| 159 | if (isNotification || isResponse) { | ||
| 160 | // For notifications and responses, return 202 Accepted | ||
| 161 | ec.web?.response?.setStatus(202) | ||
| 162 | response = "" | ||
| 163 | return | ||
| 164 | } | ||
| 165 | |||
| 166 | // For requests, build full JSON-RPC response | ||
| 167 | def responseObj = [ | ||
| 168 | jsonrpc: "2.0", | ||
| 169 | id: id | ||
| 170 | ] | ||
| 171 | |||
| 172 | if (error) { | ||
| 173 | responseObj.error = error | ||
| 174 | ec.web?.response?.setStatus(400) // Bad Request for errors | ||
| 175 | } else { | ||
| 176 | responseObj.result = result | ||
| 177 | } | ||
| 178 | |||
| 179 | def jsonResponse = new JsonBuilder(responseObj).toString() | ||
| 180 | def eventId = UUID.randomUUID().toString() | ||
| 181 | |||
| 182 | if (wantsStreaming) { | ||
| 183 | // Return as Server-Sent Events with proper format | ||
| 184 | response = "event: response\nid: ${eventId}\ndata: ${jsonResponse}\n\n" | ||
| 185 | ec.web?.response?.setContentType("text/event-stream") | ||
| 186 | ec.web?.response?.setHeader("Cache-Control", "no-cache") | ||
| 187 | ec.web?.response?.setHeader("Connection", "keep-alive") | ||
| 188 | ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*") | ||
| 189 | ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version") | ||
| 190 | } else { | ||
| 191 | response = jsonResponse | ||
| 192 | ec.web?.response?.setContentType("application/json") | ||
| 193 | ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*") | ||
| 194 | ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version") | ||
| 195 | } | ||
| 196 | |||
| 197 | // Log request for audit | ||
| 198 | ec.message.addMessage("MCP ${method} request processed${wantsStreaming ? ' (streamed)' : ''}", "info") | ||
| 199 | ]]></script> | ||
| 200 | </actions> | ||
| 201 | </service> | ||
| 202 | |||
| 203 | <!-- Helper Functions for the main service --> | ||
| 204 | |||
| 205 | <service verb="isValidOrigin" noun="Helper" authenticate="false" allow-remote="false"> | ||
| 206 | <description>Helper function to validate Origin header</description> | ||
| 207 | <in-parameters> | ||
| 208 | <parameter name="origin" required="true"/> | ||
| 209 | <parameter name="ec" type="org.moqui.context.ExecutionContext" required="true"/> | ||
| 210 | </in-parameters> | ||
| 211 | <out-parameters> | ||
| 212 | <parameter name="isValid" type="boolean"/> | ||
| 213 | </out-parameters> | ||
| 214 | <actions> | ||
| 215 | <script><![CDATA[ | ||
| 216 | // Allow localhost origins | ||
| 217 | if (origin?.startsWith("http://localhost:") || origin?.startsWith("https://localhost:")) { | ||
| 218 | isValid = true | ||
| 219 | return | ||
| 220 | } | ||
| 221 | |||
| 222 | // Allow 127.0.0.1 origins | ||
| 223 | if (origin?.startsWith("http://127.0.0.1:") || origin?.startsWith("https://127.0.0.1:")) { | ||
| 224 | isValid = true | ||
| 225 | return | ||
| 226 | } | ||
| 227 | |||
| 228 | // Allow same-origin requests (check against current host) | ||
| 229 | def currentHost = ec.web?.request?.getServerName() | ||
| 230 | def currentScheme = ec.web?.request?.getScheme() | ||
| 231 | def currentPort = ec.web?.request?.getServerPort() | ||
| 232 | |||
| 233 | def expectedOrigin = "${currentScheme}://${currentHost}" | ||
| 234 | if ((currentScheme == "http" && currentPort != 80) || (currentScheme == "https" && currentPort != 443)) { | ||
| 235 | expectedOrigin += ":${currentPort}" | ||
| 236 | } | ||
| 237 | |||
| 238 | if (origin == expectedOrigin) { | ||
| 239 | isValid = true | ||
| 240 | return | ||
| 241 | } | ||
| 242 | |||
| 243 | // Check for configured allowed origins (could be from system properties) | ||
| 244 | def allowedOrigins = ec.getFactory().getConfiguration().getStringList("moqui.mcp.allowed_origins", []) | ||
| 245 | if (allowedOrigins.contains(origin)) { | ||
| 246 | isValid = true | ||
| 247 | return | ||
| 248 | } | ||
| 249 | |||
| 250 | isValid = false | ||
| 251 | ]]></script> | ||
| 252 | </actions> | ||
| 253 | </service> | ||
| 254 | |||
| 255 | <!-- MCP Method Implementations --> | ||
| 256 | |||
| 257 | <service verb="handle" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30"> | ||
| 258 | <description>Handle MCP initialize request with Moqui authentication</description> | ||
| 259 | <in-parameters> | ||
| 260 | <parameter name="protocolVersion" required="true"/> | ||
| 261 | <parameter name="capabilities" type="Map"/> | ||
| 262 | <parameter name="clientInfo" type="Map"/> | ||
| 263 | </in-parameters> | ||
| 264 | <out-parameters> | ||
| 265 | <parameter name="result" type="Map"/> | ||
| 266 | </out-parameters> | ||
| 267 | <actions> | ||
| 268 | <script><![CDATA[ | ||
| 269 | import org.moqui.context.ExecutionContext | ||
| 270 | import groovy.json.JsonBuilder | ||
| 271 | |||
| 272 | ExecutionContext ec = context.ec | ||
| 273 | |||
| 274 | // Validate protocol version - support common MCP versions | ||
| 275 | def supportedVersions = ["2025-06-18", "2024-11-05", "2024-10-07", "2023-06-05"] | ||
| 276 | if (!supportedVersions.contains(protocolVersion)) { | ||
| 277 | throw new Exception("Unsupported protocol version: ${protocolVersion}. Supported versions: ${supportedVersions.join(', ')}") | ||
| 278 | } | ||
| 279 | |||
| 280 | // Get current user context (if authenticated) | ||
| 281 | def userId = ec.user.userId | ||
| 282 | def userAccountId = userId ? userId : null | ||
| 283 | |||
| 284 | // Build server capabilities | ||
| 285 | def serverCapabilities = [ | ||
| 286 | tools: [:], | ||
| 287 | resources: [:], | ||
| 288 | logging: [:] | ||
| 289 | ] | ||
| 290 | |||
| 291 | // Build server info | ||
| 292 | def serverInfo = [ | ||
| 293 | name: "Moqui MCP Server", | ||
| 294 | version: "2.0.0" | ||
| 295 | ] | ||
| 296 | |||
| 297 | result = [ | ||
| 298 | protocolVersion: "2025-06-18", | ||
| 299 | capabilities: serverCapabilities, | ||
| 300 | serverInfo: serverInfo, | ||
| 301 | instructions: "This server provides access to Moqui ERP services and entities through MCP. Use tools/list to discover available operations." | ||
| 302 | ] | ||
| 303 | ]]></script> | ||
| 304 | </actions> | ||
| 305 | </service> | ||
| 306 | |||
| 307 | <service verb="handle" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60"> | ||
| 308 | <description>Handle MCP tools/list request with direct Moqui service discovery</description> | ||
| 309 | <in-parameters> | ||
| 310 | <parameter name="cursor"/> | ||
| 311 | </in-parameters> | ||
| 312 | <out-parameters> | ||
| 313 | <parameter name="result" type="Map"/> | ||
| 314 | </out-parameters> | ||
| 315 | <actions> | ||
| 316 | <script><![CDATA[ | ||
| 317 | import org.moqui.context.ExecutionContext | ||
| 318 | import groovy.json.JsonBuilder | ||
| 319 | |||
| 320 | ExecutionContext ec = context.ec | ||
| 321 | |||
| 322 | // Get all service names from Moqui service engine | ||
| 323 | def allServiceNames = ec.service.getServiceNames() | ||
| 324 | def availableTools = [] | ||
| 325 | |||
| 326 | // Convert services to MCP tools | ||
| 327 | for (serviceName in allServiceNames) { | ||
| 328 | try { | ||
| 329 | // Check if user has permission | ||
| 330 | if (!ec.service.hasPermission(serviceName)) { | ||
| 331 | continue | ||
| 332 | } | ||
| 333 | |||
| 334 | def serviceInfo = ec.service.getServiceInfo(serviceName) | ||
| 335 | if (!serviceInfo) continue | ||
| 336 | |||
| 337 | // Convert service to MCP tool format | ||
| 338 | def tool = [ | ||
| 339 | name: serviceName, | ||
| 340 | description: serviceInfo.description ?: "Moqui service: ${serviceName}", | ||
| 341 | inputSchema: [ | ||
| 342 | type: "object", | ||
| 343 | properties: [:], | ||
| 344 | required: [] | ||
| 345 | ] | ||
| 346 | ] | ||
| 347 | |||
| 348 | // Convert service parameters to JSON Schema | ||
| 349 | def inParamNames = serviceInfo.getInParameterNames() | ||
| 350 | for (paramName in inParamNames) { | ||
| 351 | def paramInfo = serviceInfo.getInParameter(paramName) | ||
| 352 | tool.inputSchema.properties[paramName] = [ | ||
| 353 | type: ec.service.sync("mo-mcp.McpServices.convert#MoquiTypeToJsonSchemaType", [moquiType: paramInfo.type])?.jsonSchemaType ?: "string", | ||
| 354 | description: paramInfo.description ?: "" | ||
| 355 | ] | ||
| 356 | |||
| 357 | if (paramInfo.required) { | ||
| 358 | tool.inputSchema.required << paramName | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | availableTools << tool | ||
| 363 | |||
| 364 | } catch (Exception e) { | ||
| 365 | ec.logger.warn("Error processing service ${serviceName}: ${e.message}") | ||
| 366 | } | ||
| 367 | } | ||
| 368 | |||
| 369 | result = [ | ||
| 370 | tools: availableTools | ||
| 371 | ] | ||
| 372 | |||
| 373 | // Add pagination if needed | ||
| 374 | if (availableTools.size() >= 100) { | ||
| 375 | result.nextCursor = UUID.randomUUID().toString() | ||
| 376 | } | ||
| 377 | ]]></script> | ||
| 378 | </actions> | ||
| 379 | </service> | ||
| 380 | |||
| 381 | <service verb="handle" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300"> | ||
| 382 | <description>Handle MCP tools/call request with direct Moqui service execution</description> | ||
| 383 | <in-parameters> | ||
| 384 | <parameter name="name" required="true"/> | ||
| 385 | <parameter name="arguments" type="Map"/> | ||
| 386 | </in-parameters> | ||
| 387 | <out-parameters> | ||
| 388 | <parameter name="result" type="Map"/> | ||
| 389 | </out-parameters> | ||
| 390 | <actions> | ||
| 391 | <script><![CDATA[ | ||
| 392 | import org.moqui.context.ExecutionContext | ||
| 393 | import groovy.json.JsonBuilder | ||
| 394 | |||
| 395 | ExecutionContext ec = context.ec | ||
| 396 | |||
| 397 | // Validate service exists | ||
| 398 | if (!ec.service.isServiceDefined(name)) { | ||
| 399 | throw new Exception("Tool not found: ${name}") | ||
| 400 | } | ||
| 401 | |||
| 402 | // Check permission | ||
| 403 | if (!ec.service.hasPermission(name)) { | ||
| 404 | throw new Exception("Permission denied for tool: ${name}") | ||
| 405 | } | ||
| 406 | |||
| 407 | // Create audit record | ||
| 408 | def artifactHit = ec.entity.makeValue("moqui.server.ArtifactHit") | ||
| 409 | artifactHit.setSequencedIdPrimary() | ||
| 410 | artifactHit.visitId = ec.web?.visitId | ||
| 411 | artifactHit.userId = ec.user.userId | ||
| 412 | artifactHit.artifactType = "MCP" | ||
| 413 | artifactHit.artifactSubType = "Tool" | ||
| 414 | artifactHit.artifactName = name | ||
| 415 | artifactHit.parameterString = new JsonBuilder(arguments ?: [:]).toString() | ||
| 416 | artifactHit.startDateTime = ec.user.now | ||
| 417 | artifactHit.create() | ||
| 418 | |||
| 419 | def startTime = System.currentTimeMillis() | ||
| 420 | try { | ||
| 421 | if (wantsStreaming) { | ||
| 422 | // Streaming response for long-running operations | ||
| 423 | ec.web?.response?.setContentType("text/event-stream") | ||
| 424 | ec.web?.response?.setHeader("Cache-Control", "no-cache") | ||
| 425 | ec.web?.response?.setHeader("Connection", "keep-alive") | ||
| 426 | ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*") | ||
| 427 | ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version") | ||
| 428 | |||
| 429 | // Send start event with proper SSE format | ||
| 430 | def startEvent = new JsonBuilder([ | ||
| 431 | type: "start", | ||
| 432 | tool: name, | ||
| 433 | timestamp: ec.user.now | ||
| 434 | ]).toString() | ||
| 435 | def startEventId = UUID.randomUUID().toString() | ||
| 436 | ec.web?.response?.outputStream?.print("event: start\nid: ${startEventId}\ndata: ${startEvent}\n\n") | ||
| 437 | ec.web?.response?.outputStream?.flush() | ||
| 438 | |||
| 439 | // Execute service | ||
| 440 | def serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call() | ||
| 441 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 442 | |||
| 443 | // Send progress/result event with proper SSE format | ||
| 444 | def content = [] | ||
| 445 | if (serviceResult) { | ||
| 446 | content << [ | ||
| 447 | type: "text", | ||
| 448 | text: new JsonBuilder(serviceResult).toString() | ||
| 449 | ] | ||
| 450 | } | ||
| 451 | |||
| 452 | def resultEvent = new JsonBuilder([ | ||
| 453 | type: "result", | ||
| 454 | content: content, | ||
| 455 | isError: false, | ||
| 456 | executionTime: executionTime | ||
| 457 | ]).toString() | ||
| 458 | def resultEventId = UUID.randomUUID().toString() | ||
| 459 | ec.web?.response?.outputStream?.print("event: result\nid: ${resultEventId}\ndata: ${resultEvent}\n\n") | ||
| 460 | ec.web?.response?.outputStream?.flush() | ||
| 461 | |||
| 462 | // Send completion event with proper SSE format | ||
| 463 | def completeEvent = new JsonBuilder([ | ||
| 464 | type: "complete", | ||
| 465 | timestamp: ec.user.now | ||
| 466 | ]).toString() | ||
| 467 | def completeEventId = UUID.randomUUID().toString() | ||
| 468 | ec.web?.response?.outputStream?.print("event: complete\nid: ${completeEventId}\ndata: ${completeEvent}\n\n") | ||
| 469 | ec.web?.response?.outputStream?.flush() | ||
| 470 | |||
| 471 | result = [streamed: true] | ||
| 472 | |||
| 473 | // Update audit record | ||
| 474 | artifactHit.runningTimeMillis = executionTime | ||
| 475 | artifactHit.wasError = "N" | ||
| 476 | artifactHit.outputSize = new JsonBuilder(result).toString().length() | ||
| 477 | artifactHit.update() | ||
| 478 | |||
| 479 | } else { | ||
| 480 | // Standard non-streaming response | ||
| 481 | def serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call() | ||
| 482 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 483 | |||
| 484 | // Convert result to MCP format | ||
| 485 | def content = [] | ||
| 486 | if (serviceResult) { | ||
| 487 | content << [ | ||
| 488 | type: "text", | ||
| 489 | text: new JsonBuilder(serviceResult).toString() | ||
| 490 | ] | ||
| 491 | } | ||
| 492 | |||
| 493 | result = [ | ||
| 494 | content: content, | ||
| 495 | isError: false | ||
| 496 | ] | ||
| 497 | |||
| 498 | // Update audit record | ||
| 499 | artifactHit.runningTimeMillis = executionTime | ||
| 500 | artifactHit.wasError = "N" | ||
| 501 | artifactHit.outputSize = new JsonBuilder(result).toString().length() | ||
| 502 | artifactHit.update() | ||
| 503 | } | ||
| 504 | |||
| 505 | } catch (Exception e) { | ||
| 506 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 507 | |||
| 508 | if (wantsStreaming) { | ||
| 509 | // Send error event with proper SSE format | ||
| 510 | def errorEvent = new JsonBuilder([ | ||
| 511 | type: "error", | ||
| 512 | message: e.message, | ||
| 513 | tool: name | ||
| 514 | ]).toString() | ||
| 515 | def errorEventId = UUID.randomUUID().toString() | ||
| 516 | ec.web?.response?.outputStream?.print("event: error\nid: ${errorEventId}\ndata: ${errorEvent}\n\n") | ||
| 517 | ec.web?.response?.outputStream?.flush() | ||
| 518 | |||
| 519 | result = [streamed: true, error: true] | ||
| 520 | } else { | ||
| 521 | result = [ | ||
| 522 | content: [ | ||
| 523 | [ | ||
| 524 | type: "text", | ||
| 525 | text: "Error executing tool ${name}: ${e.message}" | ||
| 526 | ] | ||
| 527 | ], | ||
| 528 | isError: true | ||
| 529 | ] | ||
| 530 | } | ||
| 531 | |||
| 532 | // Update audit record with error | ||
| 533 | artifactHit.runningTimeMillis = executionTime | ||
| 534 | artifactHit.wasError = "Y" | ||
| 535 | artifactHit.errorMessage = e.message | ||
| 536 | artifactHit.update() | ||
| 537 | |||
| 538 | ec.logger.error("MCP tool execution error", e) | ||
| 539 | } | ||
| 540 | ]]></script> | ||
| 541 | </actions> | ||
| 542 | </service> | ||
| 543 | |||
| 544 | <service verb="handle" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60"> | ||
| 545 | <description>Handle MCP resources/list request with Moqui entity discovery</description> | ||
| 546 | <in-parameters> | ||
| 547 | <parameter name="cursor"/> | ||
| 548 | </in-parameters> | ||
| 549 | <out-parameters> | ||
| 550 | <parameter name="result" type="Map"/> | ||
| 551 | </out-parameters> | ||
| 552 | <actions> | ||
| 553 | <script><![CDATA[ | ||
| 554 | import org.moqui.context.ExecutionContext | ||
| 555 | import groovy.json.JsonBuilder | ||
| 556 | |||
| 557 | ExecutionContext ec = context.ec | ||
| 558 | |||
| 559 | // Get all entity names from Moqui entity engine | ||
| 560 | def allEntityNames = ec.entity.getEntityNames() | ||
| 561 | def availableResources = [] | ||
| 562 | |||
| 563 | // Convert entities to MCP resources | ||
| 564 | for (entityName in allEntityNames) { | ||
| 565 | try { | ||
| 566 | // Check if user has permission | ||
| 567 | if (!ec.user.hasPermission("entity:${entityName}", "VIEW")) { | ||
| 568 | continue | ||
| 569 | } | ||
| 570 | |||
| 571 | def entityInfo = ec.entity.getEntityInfo(entityName) | ||
| 572 | if (!entityInfo) continue | ||
| 573 | |||
| 574 | // Convert entity to MCP resource format | ||
| 575 | def resource = [ | ||
| 576 | uri: "entity://${entityName}", | ||
| 577 | name: entityName, | ||
| 578 | description: "Moqui entity: ${entityName}", | ||
| 579 | mimeType: "application/json" | ||
| 580 | ] | ||
| 581 | |||
| 582 | availableResources << resource | ||
| 583 | |||
| 584 | } catch (Exception e) { | ||
| 585 | ec.logger.warn("Error processing entity ${entityName}: ${e.message}") | ||
| 586 | } | ||
| 587 | } | ||
| 588 | |||
| 589 | result = [ | ||
| 590 | resources: availableResources | ||
| 591 | ] | ||
| 592 | ]]></script> | ||
| 593 | </actions> | ||
| 594 | </service> | ||
| 595 | |||
| 596 | <service verb="handle" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120"> | ||
| 597 | <description>Handle MCP resources/read request with Moqui entity queries</description> | ||
| 598 | <in-parameters> | ||
| 599 | <parameter name="uri" required="true"/> | ||
| 600 | </in-parameters> | ||
| 601 | <out-parameters> | ||
| 602 | <parameter name="result" type="Map"/> | ||
| 603 | </out-parameters> | ||
| 604 | <actions> | ||
| 605 | <script><![CDATA[ | ||
| 606 | import org.moqui.context.ExecutionContext | ||
| 607 | import groovy.json.JsonBuilder | ||
| 608 | |||
| 609 | ExecutionContext ec = context.ec | ||
| 610 | |||
| 611 | // Check if client wants streaming by looking at Accept header | ||
| 612 | def acceptHeader = ec.web?.request?.getHeader("Accept") | ||
| 613 | def wantsStreaming = acceptHeader?.contains("text/event-stream") | ||
| 614 | |||
| 615 | // Parse entity URI (format: entity://EntityName) | ||
| 616 | if (!uri.startsWith("entity://")) { | ||
| 617 | throw new Exception("Invalid resource URI: ${uri}") | ||
| 618 | } | ||
| 619 | |||
| 620 | def entityName = uri.substring(9) // Remove "entity://" prefix | ||
| 621 | |||
| 622 | // Validate entity exists | ||
| 623 | if (!ec.entity.isEntityDefined(entityName)) { | ||
| 624 | throw new Exception("Entity not found: ${entityName}") | ||
| 625 | } | ||
| 626 | |||
| 627 | // Check permission | ||
| 628 | if (!ec.user.hasPermission("entity:${entityName}", "VIEW")) { | ||
| 629 | throw new Exception("Permission denied for entity: ${entityName}") | ||
| 630 | } | ||
| 631 | |||
| 632 | // Create audit record | ||
| 633 | def artifactHit = ec.entity.makeValue("moqui.server.ArtifactHit") | ||
| 634 | artifactHit.setSequencedIdPrimary() | ||
| 635 | artifactHit.visitId = ec.web?.visitId | ||
| 636 | artifactHit.userId = ec.user.userId | ||
| 637 | artifactHit.artifactType = "MCP" | ||
| 638 | artifactHit.artifactSubType = "Resource" | ||
| 639 | artifactHit.artifactName = "resources/read" | ||
| 640 | artifactHit.parameterString = uri | ||
| 641 | artifactHit.startDateTime = ec.user.now | ||
| 642 | artifactHit.create() | ||
| 643 | |||
| 644 | def startTime = System.currentTimeMillis() | ||
| 645 | try { | ||
| 646 | // Query entity data (limited to prevent large responses) | ||
| 647 | def entityList = ec.entity.find(entityName) | ||
| 648 | .limit(100) | ||
| 649 | .list() | ||
| 650 | |||
| 651 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 652 | |||
| 653 | // Convert to MCP resource content | ||
| 654 | def contents = [ | ||
| 655 | [ | ||
| 656 | uri: uri, | ||
| 657 | mimeType: "application/json", | ||
| 658 | text: new JsonBuilder([ | ||
| 659 | entityName: entityName, | ||
| 660 | recordCount: entityList.size(), | ||
| 661 | data: entityList | ||
| 662 | ]).toString() | ||
| 663 | ] | ||
| 664 | ] | ||
| 665 | |||
| 666 | result = [ | ||
| 667 | contents: contents | ||
| 668 | ] | ||
| 669 | |||
| 670 | // Update audit record | ||
| 671 | artifactHit.runningTimeMillis = executionTime | ||
| 672 | artifactHit.wasError = "N" | ||
| 673 | artifactHit.outputSize = new JsonBuilder(result).toString().length() | ||
| 674 | artifactHit.update() | ||
| 675 | |||
| 676 | } catch (Exception e) { | ||
| 677 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 678 | |||
| 679 | // Update audit record with error | ||
| 680 | artifactHit.runningTimeMillis = executionTime | ||
| 681 | artifactHit.wasError = "Y" | ||
| 682 | artifactHit.errorMessage = e.message | ||
| 683 | artifactHit.update() | ||
| 684 | |||
| 685 | throw new Exception("Error reading resource ${uri}: ${e.message}") | ||
| 686 | } | ||
| 687 | ]]></script> | ||
| 688 | </actions> | ||
| 689 | </service> | ||
| 690 | |||
| 691 | <service verb="handle" noun="Ping" authenticate="true" allow-remote="true" transaction-timeout="10"> | ||
| 692 | <description>Handle MCP ping request for health check</description> | ||
| 693 | <in-parameters/> | ||
| 694 | <out-parameters> | ||
| 695 | <parameter name="result" type="Map"/> | ||
| 696 | </out-parameters> | ||
| 697 | <actions> | ||
| 698 | <script><![CDATA[ | ||
| 699 | result = [ | ||
| 700 | timestamp: ec.user.now, | ||
| 701 | status: "healthy", | ||
| 702 | version: "2.0.0" | ||
| 703 | ] | ||
| 704 | ]]></script> | ||
| 705 | </actions> | ||
| 706 | </service> | ||
| 707 | |||
| 708 | <!-- GET Method Support for SSE Streams --> | ||
| 709 | |||
| 710 | <service verb="handle" noun="HttpGetRequest" authenticate="true" allow-remote="true" transaction-timeout="300"> | ||
| 711 | <description>Handle MCP HTTP GET requests for SSE streams (MCP 2025-06-18 compliant)</description> | ||
| 712 | <actions> | ||
| 713 | <script><![CDATA[ | ||
| 714 | import org.moqui.context.ExecutionContext | ||
| 715 | import groovy.json.JsonBuilder | ||
| 716 | import java.util.UUID | ||
| 717 | |||
| 718 | ExecutionContext ec = context.ec | ||
| 719 | |||
| 720 | // Validate Accept header - must include text/event-stream | ||
| 721 | def acceptHeader = ec.web?.request?.getHeader("Accept") | ||
| 722 | if (!acceptHeader?.contains("text/event-stream")) { | ||
| 723 | ec.web?.response?.setStatus(406) // Not Acceptable | ||
| 724 | response = "Accept header must include text/event-stream for GET requests" | ||
| 725 | return | ||
| 726 | } | ||
| 727 | |||
| 728 | // Validate Origin header for DNS rebinding protection | ||
| 729 | def originHeader = ec.web?.request?.getHeader("Origin") | ||
| 730 | if (originHeader) { | ||
| 731 | def originValid = ec.service.sync("mo-mcp.McpJsonRpcServices.isValidOrigin#Helper", [origin: originHeader, ec: ec]).isValid | ||
| 732 | if (!originValid) { | ||
| 733 | ec.web?.response?.setStatus(403) // Forbidden | ||
| 734 | response = "Invalid Origin header" | ||
| 735 | return | ||
| 736 | } | ||
| 737 | } | ||
| 738 | |||
| 739 | // Set protocol version header | ||
| 740 | ec.web?.response?.setHeader("MCP-Protocol-Version", "2025-06-18") | ||
| 741 | |||
| 742 | // Handle session management | ||
| 743 | def sessionId = ec.web?.request?.getHeader("Mcp-Session-Id") | ||
| 744 | if (!sessionId) { | ||
| 745 | ec.web?.response?.setStatus(400) // Bad Request | ||
| 746 | response = "Mcp-Session-Id header required for GET requests" | ||
| 747 | return | ||
| 748 | } | ||
| 749 | |||
| 750 | // Set SSE headers | ||
| 751 | ec.web?.response?.setContentType("text/event-stream") | ||
| 752 | ec.web?.response?.setHeader("Cache-Control", "no-cache") | ||
| 753 | ec.web?.response?.setHeader("Connection", "keep-alive") | ||
| 754 | ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*") | ||
| 755 | ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control, Mcp-Session-Id, MCP-Protocol-Version") | ||
| 756 | |||
| 757 | // Handle Last-Event-ID for resumability | ||
| 758 | def lastEventId = ec.web?.request?.getHeader("Last-Event-ID") | ||
| 759 | |||
| 760 | // Start SSE stream with a ping event | ||
| 761 | def pingEvent = new JsonBuilder([ | ||
| 762 | type: "ping", | ||
| 763 | timestamp: ec.user.now, | ||
| 764 | sessionId: sessionId | ||
| 765 | ]).toString() | ||
| 766 | |||
| 767 | def eventId = UUID.randomUUID().toString() | ||
| 768 | response = "event: ping\nid: ${eventId}\ndata: ${pingEvent}\n\n" | ||
| 769 | |||
| 770 | // In a real implementation, you would keep the stream open and send events | ||
| 771 | // For now, we'll just send the initial ping and close | ||
| 772 | ec.message.addMessage("MCP SSE stream opened for session ${sessionId}", "info") | ||
| 773 | ]]></script> | ||
| 774 | </actions> | ||
| 775 | </service> | ||
| 776 | |||
| 777 | <!-- Helper Functions --> | ||
| 778 | |||
| 779 | <service verb="validate" noun="Origin" authenticate="false" allow-remote="false"> | ||
| 780 | <description>Validate Origin header for DNS rebinding protection</description> | ||
| 781 | <in-parameters> | ||
| 782 | <parameter name="origin" required="true"/> | ||
| 783 | </in-parameters> | ||
| 784 | <out-parameters> | ||
| 785 | <parameter name="isValid" type="boolean"/> | ||
| 786 | </out-parameters> | ||
| 787 | <actions> | ||
| 788 | <script><![CDATA[ | ||
| 789 | import org.moqui.context.ExecutionContext | ||
| 790 | |||
| 791 | ExecutionContext ec = context.ec | ||
| 792 | |||
| 793 | // Allow localhost origins | ||
| 794 | if (origin?.startsWith("http://localhost:") || origin?.startsWith("https://localhost:")) { | ||
| 795 | isValid = true | ||
| 796 | return | ||
| 797 | } | ||
| 798 | |||
| 799 | // Allow 127.0.0.1 origins | ||
| 800 | if (origin?.startsWith("http://127.0.0.1:") || origin?.startsWith("https://127.0.0.1:")) { | ||
| 801 | isValid = true | ||
| 802 | return | ||
| 803 | } | ||
| 804 | |||
| 805 | // Allow same-origin requests (check against current host) | ||
| 806 | def currentHost = ec.web?.request?.getServerName() | ||
| 807 | def currentScheme = ec.web?.request?.getScheme() | ||
| 808 | def currentPort = ec.web?.request?.getServerPort() | ||
| 809 | |||
| 810 | def expectedOrigin = "${currentScheme}://${currentHost}" | ||
| 811 | if ((currentScheme == "http" && currentPort != 80) || (currentScheme == "https" && currentPort != 443)) { | ||
| 812 | expectedOrigin += ":${currentPort}" | ||
| 813 | } | ||
| 814 | |||
| 815 | if (origin == expectedOrigin) { | ||
| 816 | isValid = true | ||
| 817 | return | ||
| 818 | } | ||
| 819 | |||
| 820 | // Check for configured allowed origins (could be from system properties) | ||
| 821 | def allowedOrigins = ec.getFactory().getConfiguration().getStringList("moqui.mcp.allowed_origins", []) | ||
| 822 | if (allowedOrigins.contains(origin)) { | ||
| 823 | isValid = true | ||
| 824 | return | ||
| 825 | } | ||
| 826 | |||
| 827 | isValid = false | ||
| 828 | ]]></script> | ||
| 829 | </actions> | ||
| 830 | </service> | ||
| 831 | |||
| 832 | <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="true" allow-remote="true"> | ||
| 833 | <description>Convert Moqui data types to JSON Schema types</description> | ||
| 834 | <in-parameters> | ||
| 835 | <parameter name="moquiType" required="true"/> | ||
| 836 | </in-parameters> | ||
| 837 | <out-parameters> | ||
| 838 | <parameter name="jsonSchemaType"/> | ||
| 839 | </out-parameters> | ||
| 840 | <actions> | ||
| 841 | <script><![CDATA[ | ||
| 842 | // Simple type mapping - can be expanded as needed | ||
| 843 | def typeMap = [ | ||
| 844 | "text-short": "string", | ||
| 845 | "text-medium": "string", | ||
| 846 | "text-long": "string", | ||
| 847 | "text-very-long": "string", | ||
| 848 | "id": "string", | ||
| 849 | "id-long": "string", | ||
| 850 | "number-integer": "integer", | ||
| 851 | "number-decimal": "number", | ||
| 852 | "number-float": "number", | ||
| 853 | "date": "string", | ||
| 854 | "date-time": "string", | ||
| 855 | "date-time-nano": "string", | ||
| 856 | "boolean": "boolean", | ||
| 857 | "text-indicator": "boolean" | ||
| 858 | ] | ||
| 859 | |||
| 860 | jsonSchemaType = typeMap[moquiType] ?: "string" | ||
| 861 | ]]></script> | ||
| 862 | </actions> | ||
| 863 | </service> | ||
| 864 | |||
| 865 | </services> | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -120,7 +120,7 @@ | ... | @@ -120,7 +120,7 @@ |
| 120 | def entityNames = [] | 120 | def entityNames = [] |
| 121 | 121 | ||
| 122 | // Get all entity names | 122 | // Get all entity names |
| 123 | def allEntityNames = ec.entity.getEntityNames() | 123 | def allEntityNames = ec.entity.getAllEntityNames() |
| 124 | 124 | ||
| 125 | // Filter to commonly used entities for demonstration | 125 | // Filter to commonly used entities for demonstration |
| 126 | def commonEntities = [ | 126 | def commonEntities = [ |
| ... | @@ -296,7 +296,7 @@ | ... | @@ -296,7 +296,7 @@ |
| 296 | ExecutionContext ec = context.ec | 296 | ExecutionContext ec = context.ec |
| 297 | 297 | ||
| 298 | // Get all service names from Moqui service engine | 298 | // Get all service names from Moqui service engine |
| 299 | def allServiceNames = ec.service.getServiceNames() | 299 | def allServiceNames = ec.service.getKnownServiceNames() |
| 300 | def availableTools = [] | 300 | def availableTools = [] |
| 301 | 301 | ||
| 302 | // Convert services to MCP tools | 302 | // Convert services to MCP tools |
| ... | @@ -456,7 +456,7 @@ | ... | @@ -456,7 +456,7 @@ |
| 456 | ExecutionContext ec = context.ec | 456 | ExecutionContext ec = context.ec |
| 457 | 457 | ||
| 458 | // Get all entity names from Moqui entity engine | 458 | // Get all entity names from Moqui entity engine |
| 459 | def allEntityNames = ec.entity.getEntityNames() | 459 | def allEntityNames = ec.entity.getAllEntityNames() |
| 460 | def availableResources = [] | 460 | def availableResources = [] |
| 461 | 461 | ||
| 462 | // Convert entities to MCP resources | 462 | // Convert entities to MCP resources |
| ... | @@ -583,21 +583,109 @@ | ... | @@ -583,21 +583,109 @@ |
| 583 | <description>Handle MCP ping request for health check</description> | 583 | <description>Handle MCP ping request for health check</description> |
| 584 | <in-parameters/> | 584 | <in-parameters/> |
| 585 | <out-parameters> | 585 | <out-parameters> |
| 586 | <parameter name="timestamp" type="date-time"/> | 586 | <parameter name="result" type="Map"/> |
| 587 | <parameter name="status" type="text-indicator"/> | ||
| 588 | <parameter name="version"/> | ||
| 589 | </out-parameters> | 587 | </out-parameters> |
| 590 | <actions> | 588 | <actions> |
| 591 | <script><![CDATA[ | 589 | <script><![CDATA[ |
| 592 | timestamp = ec.user.getNowTimestamp() | 590 | result = [ |
| 593 | status = "healthy" | 591 | timestamp: ec.user.getNowTimestamp(), |
| 594 | version = "2.0.0" | 592 | status: "healthy", |
| 593 | version: "2.0.0" | ||
| 594 | ] | ||
| 595 | ]]></script> | ||
| 596 | </actions> | ||
| 597 | </service> | ||
| 598 | |||
| 599 | <!-- Debug Service --> | ||
| 600 | |||
| 601 | <service verb="debug" noun="ComponentStatus" authenticate="false" allow-remote="true"> | ||
| 602 | <description>Debug service to verify component is loaded and working</description> | ||
| 603 | <in-parameters/> | ||
| 604 | <out-parameters> | ||
| 605 | <parameter name="status" type="Map"/> | ||
| 606 | </out-parameters> | ||
| 607 | <actions> | ||
| 608 | <script><![CDATA[ | ||
| 609 | import org.moqui.context.ExecutionContext | ||
| 610 | |||
| 611 | ExecutionContext ec = context.ec | ||
| 612 | |||
| 613 | def status = [ | ||
| 614 | componentLoaded: true, | ||
| 615 | componentName: "mo-mcp", | ||
| 616 | timestamp: ec.user.getNowTimestamp(), | ||
| 617 | user: ec.user.username, | ||
| 618 | userId: ec.user.userId, | ||
| 619 | serviceNames: ec.service.getKnownServiceNames().findAll { it.contains("Mcp") }, | ||
| 620 | entityNames: ec.entity.getAllEntityNames().findAll { it.contains("ArtifactHit") } | ||
| 621 | ] | ||
| 622 | |||
| 623 | ec.logger.info("=== MCP COMPONENT DEBUG ===") | ||
| 624 | ec.logger.info("Component status: ${status}") | ||
| 625 | ec.logger.info("All service names count: ${ec.service.getKnownServiceNames().size()}") | ||
| 626 | ec.logger.info("All entity names count: ${ec.entity.getAllEntityNames().size()}") | ||
| 627 | ec.logger.info("=== END MCP COMPONENT DEBUG ===") | ||
| 628 | |||
| 629 | result.status = status | ||
| 595 | ]]></script> | 630 | ]]></script> |
| 596 | </actions> | 631 | </actions> |
| 597 | </service> | 632 | </service> |
| 598 | 633 | ||
| 599 | <!-- Helper Functions --> | 634 | <!-- Helper Functions --> |
| 600 | 635 | ||
| 636 | <service verb="validate" noun="Origin" authenticate="false" allow-remote="false"> | ||
| 637 | <description>Validate Origin header for DNS rebinding protection</description> | ||
| 638 | <in-parameters> | ||
| 639 | <parameter name="origin" required="true"/> | ||
| 640 | </in-parameters> | ||
| 641 | <out-parameters> | ||
| 642 | <parameter name="isValid" type="boolean"/> | ||
| 643 | </out-parameters> | ||
| 644 | <actions> | ||
| 645 | <script><![CDATA[ | ||
| 646 | import org.moqui.context.ExecutionContext | ||
| 647 | |||
| 648 | ExecutionContext ec = context.ec | ||
| 649 | |||
| 650 | // Allow localhost origins | ||
| 651 | if (origin?.startsWith("http://localhost:") || origin?.startsWith("https://localhost:")) { | ||
| 652 | isValid = true | ||
| 653 | return | ||
| 654 | } | ||
| 655 | |||
| 656 | // Allow 127.0.0.1 origins | ||
| 657 | if (origin?.startsWith("http://127.0.0.1:") || origin?.startsWith("https://127.0.0.1:")) { | ||
| 658 | isValid = true | ||
| 659 | return | ||
| 660 | } | ||
| 661 | |||
| 662 | // Allow same-origin requests (check against current host) | ||
| 663 | def currentHost = ec.web?.request?.getServerName() | ||
| 664 | def currentScheme = ec.web?.request?.getScheme() | ||
| 665 | def currentPort = ec.web?.request?.getServerPort() | ||
| 666 | |||
| 667 | def expectedOrigin = "${currentScheme}://${currentHost}" | ||
| 668 | if ((currentScheme == "http" && currentPort != 80) || (currentScheme == "https" && currentPort != 443)) { | ||
| 669 | expectedOrigin += ":${currentPort}" | ||
| 670 | } | ||
| 671 | |||
| 672 | if (origin == expectedOrigin) { | ||
| 673 | isValid = true | ||
| 674 | return | ||
| 675 | } | ||
| 676 | |||
| 677 | // Check for configured allowed origins (could be from system properties) | ||
| 678 | def allowedOrigins = ec.getFactory().getConfiguration().getStringList("moqui.mcp.allowed_origins", []) | ||
| 679 | if (allowedOrigins.contains(origin)) { | ||
| 680 | isValid = true | ||
| 681 | return | ||
| 682 | } | ||
| 683 | |||
| 684 | isValid = false | ||
| 685 | ]]></script> | ||
| 686 | </actions> | ||
| 687 | </service> | ||
| 688 | |||
| 601 | <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="false"> | 689 | <service verb="convert" noun="MoquiTypeToJsonSchemaType" authenticate="false"> |
| 602 | <description>Convert Moqui data types to JSON Schema types</description> | 690 | <description>Convert Moqui data types to JSON Schema types</description> |
| 603 | <in-parameters> | 691 | <in-parameters> |
| ... | @@ -648,17 +736,136 @@ | ... | @@ -648,17 +736,136 @@ |
| 648 | <script><![CDATA[ | 736 | <script><![CDATA[ |
| 649 | import groovy.json.JsonBuilder | 737 | import groovy.json.JsonBuilder |
| 650 | import org.moqui.context.ExecutionContext | 738 | import org.moqui.context.ExecutionContext |
| 739 | import java.util.UUID | ||
| 651 | 740 | ||
| 652 | ExecutionContext ec = context.ec | 741 | ExecutionContext ec = context.ec |
| 653 | 742 | ||
| 654 | // Check Accept header to determine if client wants streaming response | 743 | // DEBUG: Log initial request details |
| 744 | ec.logger.info("=== MCP REQUEST DEBUG START ===") | ||
| 745 | ec.logger.info("MCP Request - Method: ${method}, ID: ${id}") | ||
| 746 | ec.logger.info("MCP Request - Params: ${params}") | ||
| 747 | ec.logger.info("MCP Request - User: ${ec.user.username}, UserID: ${ec.user.userId}") | ||
| 748 | ec.logger.info("MCP Request - Current Time: ${ec.user.getNowTimestamp()}") | ||
| 749 | |||
| 750 | // DEBUG: Log HTTP request details | ||
| 751 | def httpRequest = ec.web?.request | ||
| 752 | if (httpRequest) { | ||
| 753 | ec.logger.info("HTTP Method: ${httpRequest.method}") | ||
| 754 | ec.logger.info("HTTP Content-Type: ${httpRequest.getContentType()}") | ||
| 755 | ec.logger.info("HTTP Accept: ${httpRequest.getHeader('Accept')}") | ||
| 756 | ec.logger.info("HTTP Origin: ${httpRequest.getHeader('Origin')}") | ||
| 757 | ec.logger.info("HTTP User-Agent: ${httpRequest.getHeader('User-Agent')}") | ||
| 758 | ec.logger.info("HTTP Remote Addr: ${httpRequest.getRemoteAddr()}") | ||
| 759 | ec.logger.info("HTTP Request URL: ${httpRequest.getRequestURL()}") | ||
| 760 | ec.logger.info("HTTP Query String: ${httpRequest.getQueryString()}") | ||
| 761 | } else { | ||
| 762 | ec.logger.warn("HTTP Request object is null!") | ||
| 763 | } | ||
| 764 | |||
| 765 | // DEBUG: Log HTTP response details | ||
| 766 | if (httpResponse) { | ||
| 767 | ec.logger.info("HTTP Response Status: ${httpResponse.status}") | ||
| 768 | } else { | ||
| 769 | ec.logger.warn("HTTP Response object is null!") | ||
| 770 | } | ||
| 771 | |||
| 772 | // Validate HTTP method - only POST allowed for JSON-RPC messages | ||
| 773 | def httpMethod = ec.web?.request?.method | ||
| 774 | ec.logger.info("Validating HTTP method: ${httpMethod}") | ||
| 775 | if (httpMethod != "POST") { | ||
| 776 | ec.logger.warn("Invalid HTTP method: ${httpMethod}, expected POST") | ||
| 777 | ec.web?.response?.setStatus(405) // Method Not Allowed | ||
| 778 | ec.web?.response?.setHeader("Allow", "POST") | ||
| 779 | response = "Method Not Allowed. Use POST for JSON-RPC messages." | ||
| 780 | return | ||
| 781 | } | ||
| 782 | ec.logger.info("HTTP method validation passed") | ||
| 783 | |||
| 784 | // Validate Content-Type header for POST requests | ||
| 785 | def contentType = ec.web?.request?.getContentType() | ||
| 786 | ec.logger.info("Validating Content-Type: ${contentType}") | ||
| 787 | if (!contentType?.contains("application/json")) { | ||
| 788 | ec.logger.warn("Invalid Content-Type: ${contentType}, expected application/json") | ||
| 789 | ec.web?.response?.setStatus(415) // Unsupported Media Type | ||
| 790 | response = "Content-Type must be application/json for JSON-RPC messages" | ||
| 791 | return | ||
| 792 | } | ||
| 793 | ec.logger.info("Content-Type validation passed") | ||
| 794 | |||
| 795 | // Validate Accept header - must accept application/json for MVP | ||
| 655 | def acceptHeader = ec.web?.request?.getHeader("Accept") | 796 | def acceptHeader = ec.web?.request?.getHeader("Accept") |
| 656 | def wantsStreaming = acceptHeader && acceptHeader.contains("text/event-stream") | 797 | ec.logger.info("Validating Accept header: ${acceptHeader}") |
| 798 | if (!acceptHeader?.contains("application/json")) { | ||
| 799 | ec.logger.warn("Invalid Accept header: ${acceptHeader}") | ||
| 800 | ec.web?.response?.setStatus(406) // Not Acceptable | ||
| 801 | response = "Accept header must include application/json for JSON-RPC" | ||
| 802 | return | ||
| 803 | } | ||
| 804 | ec.logger.info("Accept header validation passed") | ||
| 805 | |||
| 806 | // Validate Content-Type header for POST requests | ||
| 807 | if (!contentType?.contains("application/json")) { | ||
| 808 | ec.web?.response?.setStatus(415) // Unsupported Media Type | ||
| 809 | response = "Content-Type must be application/json for JSON-RPC messages" | ||
| 810 | return | ||
| 811 | } | ||
| 812 | |||
| 813 | // Validate Origin header for DNS rebinding protection | ||
| 814 | def originHeader = ec.web?.request?.getHeader("Origin") | ||
| 815 | ec.logger.info("Checking Origin header: ${originHeader}") | ||
| 816 | if (originHeader) { | ||
| 817 | try { | ||
| 818 | def originValid = ec.service.sync("mo-mcp.McpServices.validate#Origin", [origin: originHeader]).isValid | ||
| 819 | ec.logger.info("Origin validation result: ${originValid}") | ||
| 820 | if (!originValid) { | ||
| 821 | ec.logger.warn("Invalid Origin header rejected: ${originHeader}") | ||
| 822 | ec.web?.response?.setStatus(403) // Forbidden | ||
| 823 | response = "Invalid Origin header" | ||
| 824 | return | ||
| 825 | } | ||
| 826 | } catch (Exception e) { | ||
| 827 | ec.logger.error("Error during Origin validation", e) | ||
| 828 | ec.web?.response?.setStatus(500) // Internal Server Error | ||
| 829 | response = "Error during Origin validation: ${e.message}" | ||
| 830 | return | ||
| 831 | } | ||
| 832 | } else { | ||
| 833 | ec.logger.info("No Origin header present") | ||
| 834 | } | ||
| 835 | |||
| 836 | // Force non-streaming for MVP - always use JSON-RPC 2.0 | ||
| 837 | def wantsStreaming = false | ||
| 838 | ec.logger.info("Streaming disabled for MVP - using JSON-RPC 2.0") | ||
| 839 | |||
| 840 | // Set protocol version header on all responses | ||
| 841 | ec.web?.response?.setHeader("MCP-Protocol-Version", "2025-06-18") | ||
| 842 | ec.logger.info("Set MCP protocol version header") | ||
| 843 | |||
| 844 | // Handle session management | ||
| 845 | def sessionId = ec.web?.request?.getHeader("Mcp-Session-Id") | ||
| 846 | def isInitialize = (method == "initialize") | ||
| 847 | ec.logger.info("Session management - SessionId: ${sessionId}, IsInitialize: ${isInitialize}") | ||
| 848 | |||
| 849 | if (!isInitialize && !sessionId) { | ||
| 850 | ec.logger.warn("Missing session ID for non-initialization request") | ||
| 851 | ec.web?.response?.setStatus(400) // Bad Request | ||
| 852 | response = "Mcp-Session-Id header required for non-initialization requests" | ||
| 853 | return | ||
| 854 | } | ||
| 855 | |||
| 856 | // Generate new session ID for initialization | ||
| 857 | if (isInitialize) { | ||
| 858 | def newSessionId = UUID.randomUUID().toString() | ||
| 859 | ec.web?.response?.setHeader("Mcp-Session-Id", newSessionId) | ||
| 860 | ec.logger.info("Generated new session ID: ${newSessionId}") | ||
| 861 | } | ||
| 657 | 862 | ||
| 658 | ec.logger.info("MCP ${method} :: ${params} STREAMING ${wantsStreaming}") | 863 | ec.logger.info("MCP ${method} :: ${params} STREAMING ${wantsStreaming}") |
| 659 | 864 | ||
| 660 | // Validate JSON-RPC version | 865 | // Validate JSON-RPC version |
| 866 | ec.logger.info("Validating JSON-RPC version: ${jsonrpc}") | ||
| 661 | if (jsonrpc && jsonrpc != "2.0") { | 867 | if (jsonrpc && jsonrpc != "2.0") { |
| 868 | ec.logger.warn("Invalid JSON-RPC version: ${jsonrpc}") | ||
| 662 | def errorResponse = new JsonBuilder([ | 869 | def errorResponse = new JsonBuilder([ |
| 663 | jsonrpc: "2.0", | 870 | jsonrpc: "2.0", |
| 664 | error: [ | 871 | error: [ |
| ... | @@ -668,13 +875,18 @@ | ... | @@ -668,13 +875,18 @@ |
| 668 | id: id | 875 | id: id |
| 669 | ]).toString() | 876 | ]).toString() |
| 670 | 877 | ||
| 878 | ec.logger.info("Built JSON-RPC error response: ${errorResponse}") | ||
| 879 | |||
| 671 | if (wantsStreaming) { | 880 | if (wantsStreaming) { |
| 672 | response = "data: ${errorResponse}\n\n" | 881 | response = "event: error\ndata: ${errorResponse}\n\n" |
| 882 | ec.logger.info("Returning streaming error response") | ||
| 673 | } else { | 883 | } else { |
| 674 | response = errorResponse | 884 | response = errorResponse |
| 885 | ec.logger.info("Returning regular error response") | ||
| 675 | } | 886 | } |
| 676 | return | 887 | return |
| 677 | } | 888 | } |
| 889 | ec.logger.info("JSON-RPC version validation passed") | ||
| 678 | 890 | ||
| 679 | def result = null | 891 | def result = null |
| 680 | def error = null | 892 | def error = null |
| ... | @@ -682,32 +894,40 @@ | ... | @@ -682,32 +894,40 @@ |
| 682 | try { | 894 | try { |
| 683 | // Map OpenCode method names to actual service names | 895 | // Map OpenCode method names to actual service names |
| 684 | def serviceName = null | 896 | def serviceName = null |
| 897 | ec.logger.info("Mapping method '${method}' to service name") | ||
| 685 | switch (method) { | 898 | switch (method) { |
| 686 | case "mcp#Ping": | 899 | case "mcp#Ping": |
| 687 | case "ping": | 900 | case "ping": |
| 688 | serviceName = "McpServices.mcp#Ping" | 901 | serviceName = "McpServices.mcp#Ping" |
| 902 | ec.logger.info("Mapped to service: ${serviceName}") | ||
| 689 | break | 903 | break |
| 690 | case "initialize": | 904 | case "initialize": |
| 691 | case "mcp#Initialize": | 905 | case "mcp#Initialize": |
| 692 | serviceName = "McpServices.mcp#Initialize" | 906 | serviceName = "McpServices.mcp#Initialize" |
| 907 | ec.logger.info("Mapped to service: ${serviceName}") | ||
| 693 | break | 908 | break |
| 694 | case "tools/list": | 909 | case "tools/list": |
| 695 | case "mcp#ToolsList": | 910 | case "mcp#ToolsList": |
| 696 | serviceName = "McpServices.mcp#ToolsList" | 911 | serviceName = "McpServices.mcp#ToolsList" |
| 912 | ec.logger.info("Mapped to service: ${serviceName}") | ||
| 697 | break | 913 | break |
| 698 | case "tools/call": | 914 | case "tools/call": |
| 699 | case "mcp#ToolsCall": | 915 | case "mcp#ToolsCall": |
| 700 | serviceName = "McpServices.mcp#ToolsCall" | 916 | serviceName = "McpServices.mcp#ToolsCall" |
| 917 | ec.logger.info("Mapped to service: ${serviceName}") | ||
| 701 | break | 918 | break |
| 702 | case "resources/list": | 919 | case "resources/list": |
| 703 | case "mcp#ResourcesList": | 920 | case "mcp#ResourcesList": |
| 704 | serviceName = "McpServices.mcp#ResourcesList" | 921 | serviceName = "McpServices.mcp#ResourcesList" |
| 922 | ec.logger.info("Mapped to service: ${serviceName}") | ||
| 705 | break | 923 | break |
| 706 | case "resources/read": | 924 | case "resources/read": |
| 707 | case "mcp#ResourcesRead": | 925 | case "mcp#ResourcesRead": |
| 708 | serviceName = "McpServices.mcp#ResourcesRead" | 926 | serviceName = "McpServices.mcp#ResourcesRead" |
| 927 | ec.logger.info("Mapped to service: ${serviceName}") | ||
| 709 | break | 928 | break |
| 710 | default: | 929 | default: |
| 930 | ec.logger.warn("Unknown method: ${method}") | ||
| 711 | error = [ | 931 | error = [ |
| 712 | code: -32601, | 932 | code: -32601, |
| 713 | message: "Method not found: ${method}" | 933 | message: "Method not found: ${method}" |
| ... | @@ -715,12 +935,29 @@ | ... | @@ -715,12 +935,29 @@ |
| 715 | } | 935 | } |
| 716 | 936 | ||
| 717 | if (serviceName && !error) { | 937 | if (serviceName && !error) { |
| 938 | ec.logger.info("Calling service: ${serviceName} with params: ${params}") | ||
| 939 | // Check if service exists before calling | ||
| 940 | if (!ec.service.isServiceDefined(serviceName)) { | ||
| 941 | ec.logger.error("Service not defined: ${serviceName}") | ||
| 942 | error = [ | ||
| 943 | code: -32601, | ||
| 944 | message: "Service not found: ${serviceName}" | ||
| 945 | ] | ||
| 946 | } else { | ||
| 718 | // Call the actual MCP service (services now return Maps, no streaming logic) | 947 | // Call the actual MCP service (services now return Maps, no streaming logic) |
| 948 | def serviceStartTime = System.currentTimeMillis() | ||
| 719 | result = ec.service.sync().name(serviceName).parameters(params ?: [:]).call() | 949 | result = ec.service.sync().name(serviceName).parameters(params ?: [:]).call() |
| 950 | def serviceEndTime = System.currentTimeMillis() | ||
| 951 | ec.logger.info("Service ${serviceName} completed in ${serviceEndTime - serviceStartTime}ms") | ||
| 952 | ec.logger.info("Service result type: ${result?.getClass()?.getSimpleName()}") | ||
| 953 | ec.logger.info("Service result: ${result}") | ||
| 954 | } | ||
| 720 | } | 955 | } |
| 721 | 956 | ||
| 722 | } catch (Exception e) { | 957 | } catch (Exception e) { |
| 723 | ec.logger.error("MCP request error for method ${method}", e) | 958 | ec.logger.error("MCP request error for method ${method}", e) |
| 959 | ec.logger.error("Exception details: ${e.getClass().getName()}: ${e.message}") | ||
| 960 | ec.logger.error("Exception stack trace: ${e.getStackTrace()}") | ||
| 724 | error = [ | 961 | error = [ |
| 725 | code: -32603, | 962 | code: -32603, |
| 726 | message: "Internal error: ${e.message}" | 963 | message: "Internal error: ${e.message}" |
| ... | @@ -728,6 +965,7 @@ | ... | @@ -728,6 +965,7 @@ |
| 728 | } | 965 | } |
| 729 | 966 | ||
| 730 | // Build JSON-RPC response | 967 | // Build JSON-RPC response |
| 968 | ec.logger.info("Building JSON-RPC response") | ||
| 731 | def responseObj = [ | 969 | def responseObj = [ |
| 732 | jsonrpc: "2.0", | 970 | jsonrpc: "2.0", |
| 733 | id: id | 971 | id: id |
| ... | @@ -735,23 +973,29 @@ | ... | @@ -735,23 +973,29 @@ |
| 735 | 973 | ||
| 736 | if (error) { | 974 | if (error) { |
| 737 | responseObj.error = error | 975 | responseObj.error = error |
| 976 | ec.logger.info("Response includes error: ${error}") | ||
| 738 | } else { | 977 | } else { |
| 739 | responseObj.result = result | 978 | responseObj.result = result |
| 979 | ec.logger.info("Response includes result") | ||
| 740 | } | 980 | } |
| 741 | 981 | ||
| 742 | def jsonResponse = new JsonBuilder(responseObj).toString() | 982 | def jsonResponse = new JsonBuilder(responseObj).toString() |
| 983 | ec.logger.info("Built JSON response: ${jsonResponse}") | ||
| 984 | ec.logger.info("JSON response length: ${jsonResponse.length()}") | ||
| 743 | 985 | ||
| 744 | if (wantsStreaming) { | 986 | def httpResponse = ec.web?.response |
| 745 | // Set streaming headers and return as Server-Sent Events | 987 | |
| 746 | ec.web?.response?.setContentType("text/event-stream") | 988 | // MVP: Always return JSON-RPC 2.0, no streaming |
| 747 | ec.web?.response?.setHeader("Cache-Control", "no-cache") | 989 | ec.logger.info("Creating JSON-RPC 2.0 response") |
| 748 | ec.web?.response?.setHeader("Connection", "keep-alive") | 990 | if (httpResponse) { |
| 749 | ec.web?.response?.setHeader("Access-Control-Allow-Origin", "*") | 991 | httpResponse.setContentType("application/json") |
| 750 | ec.web?.response?.setHeader("Access-Control-Allow-Headers", "Cache-Control") | 992 | httpResponse.setHeader("Content-Type", "application/json") |
| 751 | response = "data: ${jsonResponse}\n\n" | 993 | ec.logger.info("Set JSON-RPC content type") |
| 752 | } else { | ||
| 753 | response = jsonResponse | ||
| 754 | } | 994 | } |
| 995 | response = jsonResponse | ||
| 996 | ec.logger.info("Created JSON-RPC response, length: ${response.length()}") | ||
| 997 | |||
| 998 | ec.logger.info("=== MCP REQUEST DEBUG END ===") | ||
| 755 | ]]></script> | 999 | ]]></script> |
| 756 | </actions> | 1000 | </actions> |
| 757 | </service> | 1001 | </service> | ... | ... |
| ... | @@ -15,12 +15,23 @@ | ... | @@ -15,12 +15,23 @@ |
| 15 | description="MCP JSON-RPC 2.0 services for Moqui integration"> | 15 | description="MCP JSON-RPC 2.0 services for Moqui integration"> |
| 16 | 16 | ||
| 17 | <resource name="rpc"> | 17 | <resource name="rpc"> |
| 18 | <method type="post"> | 18 | <method type="post" content-type="application/json"> |
| 19 | <service name="McpServices.handle#McpRequest"/> | ||
| 20 | </method> | ||
| 21 | <method type="post" content-type="application/json-rpc"> | ||
| 19 | <service name="McpServices.handle#McpRequest"/> | 22 | <service name="McpServices.handle#McpRequest"/> |
| 20 | </method> | 23 | </method> |
| 24 | |||
| 21 | <method type="get"> | 25 | <method type="get"> |
| 22 | <service name="McpServices.mcp#Ping"/> | 26 | <service name="McpServices.mcp#Ping"/> |
| 23 | </method> | 27 | </method> |
| 28 | <method type="get" path="debug"> | ||
| 29 | <service name="McpServices.debug#ComponentStatus"/> | ||
| 30 | </method> | ||
| 31 | <!-- Add a catch-all method for debugging --> | ||
| 32 | <method type="post"> | ||
| 33 | <service name="McpServices.handle#McpRequest"/> | ||
| 34 | </method> | ||
| 24 | </resource> | 35 | </resource> |
| 25 | 36 | ||
| 26 | 37 | ... | ... |
-
Please register or sign in to post a comment