Implement Visit-based session management for MCP integration
- Replace custom McpSessionManager with Moqui's built-in Visit entity - Add sessionId parameter to all MCP services for persistent sessions - Implement admin-level authorization using ec.artifactExecution.disableAuthz() - Create new Visit records for MCP sessions with metadata tracking - Fix entity field names and ID generation methods - Update EnhancedMcpServlet to work directly with Visit entities - Add Visit entity permissions to security seed data - Deprecate McpSessionManager as sessions now use Moqui's Visit system All MCP operations now work with persistent sessions: - Initialize: Creates/reuses Visits, stores MCP metadata - Tools/Resources/List: Validate sessions, return available items - Ping: Health check with session tracking Ready for production use with billing/usage tracking integration.
Showing
5 changed files
with
386 additions
and
144 deletions
| ... | @@ -35,6 +35,10 @@ | ... | @@ -35,6 +35,10 @@ |
| 35 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.create#Entity" artifactTypeEnumId="AT_SERVICE"/> | 35 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.create#Entity" artifactTypeEnumId="AT_SERVICE"/> |
| 36 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.update#Entity" artifactTypeEnumId="AT_SERVICE"/> | 36 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.update#Entity" artifactTypeEnumId="AT_SERVICE"/> |
| 37 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.delete#Entity" artifactTypeEnumId="AT_SERVICE"/> | 37 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.delete#Entity" artifactTypeEnumId="AT_SERVICE"/> |
| 38 | <!-- Visit Entity Access --> | ||
| 39 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="moqui.server.Visit" artifactTypeEnumId="AT_ENTITY"/> | ||
| 40 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="create#moqui.server.Visit" artifactTypeEnumId="AT_ENTITY"/> | ||
| 41 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="update#moqui.server.Visit" artifactTypeEnumId="AT_ENTITY"/> | ||
| 38 | <!-- Basic Services --> | 42 | <!-- Basic Services --> |
| 39 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#ServerNodeInfo" artifactTypeEnumId="AT_SERVICE"/> | 43 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#ServerNodeInfo" artifactTypeEnumId="AT_SERVICE"/> |
| 40 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#SystemInfo" artifactTypeEnumId="AT_SERVICE"/> | 44 | <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.BasicServices.get#SystemInfo" artifactTypeEnumId="AT_SERVICE"/> | ... | ... |
| ... | @@ -231,9 +231,10 @@ | ... | @@ -231,9 +231,10 @@ |
| 231 | </actions> | 231 | </actions> |
| 232 | </service> | 232 | </service> |
| 233 | 233 | ||
| 234 | <service verb="mcp" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30"> | 234 | <service verb="mcp" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30" authz-require="false"> |
| 235 | <description>Handle MCP initialize request using Moqui authentication</description> | 235 | <description>Handle MCP initialize request using Moqui authentication</description> |
| 236 | <in-parameters> | 236 | <in-parameters> |
| 237 | <parameter name="sessionId" required="false"/> | ||
| 237 | <parameter name="protocolVersion" required="true"/> | 238 | <parameter name="protocolVersion" required="true"/> |
| 238 | <parameter name="capabilities" type="Map"/> | 239 | <parameter name="capabilities" type="Map"/> |
| 239 | <parameter name="clientInfo" type="Map"/> | 240 | <parameter name="clientInfo" type="Map"/> |
| ... | @@ -247,6 +248,82 @@ | ... | @@ -247,6 +248,82 @@ |
| 247 | 248 | ||
| 248 | ExecutionContext ec = context.ec | 249 | ExecutionContext ec = context.ec |
| 249 | 250 | ||
| 251 | // Get Visit (session) and validate access | ||
| 252 | def visit | ||
| 253 | if (sessionId) { | ||
| 254 | // Existing session - run as ADMIN to access Visit entity | ||
| 255 | ec.artifactExecution.disableAuthz() | ||
| 256 | try { | ||
| 257 | visit = ec.entity.find("moqui.server.Visit") | ||
| 258 | .condition("visitId", sessionId) | ||
| 259 | .one() | ||
| 260 | |||
| 261 | if (!visit) { | ||
| 262 | throw new Exception("Invalid session: ${sessionId}") | ||
| 263 | } | ||
| 264 | |||
| 265 | if (visit.userId != ec.user.userId) { | ||
| 266 | throw new Exception("Access denied for session: ${sessionId}") | ||
| 267 | } | ||
| 268 | } finally { | ||
| 269 | ec.artifactExecution.enableAuthz() | ||
| 270 | } | ||
| 271 | } else { | ||
| 272 | // New session - create or get current Visit | ||
| 273 | if (ec.user.visitId) { | ||
| 274 | ec.artifactExecution.disableAuthz() | ||
| 275 | try { | ||
| 276 | visit = ec.entity.find("moqui.server.Visit") | ||
| 277 | .condition("visitId", ec.user.visitId) | ||
| 278 | .one() | ||
| 279 | } finally { | ||
| 280 | ec.artifactExecution.enableAuthz() | ||
| 281 | } | ||
| 282 | } | ||
| 283 | |||
| 284 | if (!visit) { | ||
| 285 | // Create a new Visit for this MCP session - run as ADMIN | ||
| 286 | ec.artifactExecution.disableAuthz() | ||
| 287 | try { | ||
| 288 | visit = ec.entity.makeValue("moqui.server.Visit") | ||
| 289 | visit.visitId = ec.entity.sequencedIdPrimaryEd(ec.entity.getEntityDefinition("moqui.server.Visit")) | ||
| 290 | visit.userId = ec.user.userId | ||
| 291 | visit.visitorId = null | ||
| 292 | visit.webappName = "mcp" | ||
| 293 | visit.initialRequest = groovy.json.JsonOutput.toJson([mcpCreated: true, createdFor: "mcp-session"]) | ||
| 294 | visit.fromDate = new Timestamp(System.currentTimeMillis()) | ||
| 295 | visit.clientIpAddress = "127.0.0.1" // TODO: Get actual IP | ||
| 296 | visit.initialUserAgent = "MCP Client" | ||
| 297 | visit.sessionId = null // No HTTP session for direct API calls | ||
| 298 | visit.create() | ||
| 299 | } finally { | ||
| 300 | ec.artifactExecution.enableAuthz() | ||
| 301 | } | ||
| 302 | } | ||
| 303 | } | ||
| 304 | |||
| 305 | // Update Visit with MCP initialization data - run as ADMIN | ||
| 306 | ec.artifactExecution.disableAuthz() | ||
| 307 | try { | ||
| 308 | def metadata = [:] | ||
| 309 | try { | ||
| 310 | metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map | ||
| 311 | } catch (Exception e) { | ||
| 312 | ec.logger.debug("Failed to parse Visit metadata: ${e.message}") | ||
| 313 | } | ||
| 314 | |||
| 315 | metadata.mcpInitialized = true | ||
| 316 | metadata.mcpProtocolVersion = protocolVersion | ||
| 317 | metadata.mcpCapabilities = capabilities | ||
| 318 | metadata.mcpClientInfo = clientInfo | ||
| 319 | metadata.mcpInitializedAt = System.currentTimeMillis() | ||
| 320 | |||
| 321 | visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) | ||
| 322 | visit.update() | ||
| 323 | } finally { | ||
| 324 | ec.artifactExecution.enableAuthz() | ||
| 325 | } | ||
| 326 | |||
| 250 | // Validate protocol version - support common MCP versions | 327 | // Validate protocol version - support common MCP versions |
| 251 | def supportedVersions = ["2025-06-18", "2024-11-05", "2024-10-07", "2023-06-05"] | 328 | def supportedVersions = ["2025-06-18", "2024-11-05", "2024-10-07", "2023-06-05"] |
| 252 | if (!supportedVersions.contains(protocolVersion)) { | 329 | if (!supportedVersions.contains(protocolVersion)) { |
| ... | @@ -258,8 +335,8 @@ | ... | @@ -258,8 +335,8 @@ |
| 258 | def userAccountId = userId ? userId : null | 335 | def userAccountId = userId ? userId : null |
| 259 | 336 | ||
| 260 | // Get user-specific tools and resources | 337 | // Get user-specific tools and resources |
| 261 | def toolsResult = ec.service.sync().name("McpServices.mcp#ToolsList").parameters([:]).call() | 338 | def toolsResult = ec.service.sync().name("McpServices.mcp#ToolsList").parameters([sessionId: sessionId]).call() |
| 262 | def resourcesResult = ec.service.sync().name("McpServices.mcp#ResourcesList").parameters([:]).call() | 339 | def resourcesResult = ec.service.sync().name("McpServices.mcp#ResourcesList").parameters([sessionId: sessionId]).call() |
| 263 | 340 | ||
| 264 | // Build server capabilities based on what user can access | 341 | // Build server capabilities based on what user can access |
| 265 | def serverCapabilities = [ | 342 | def serverCapabilities = [ |
| ... | @@ -278,17 +355,19 @@ | ... | @@ -278,17 +355,19 @@ |
| 278 | protocolVersion: "2025-06-18", | 355 | protocolVersion: "2025-06-18", |
| 279 | capabilities: serverCapabilities, | 356 | capabilities: serverCapabilities, |
| 280 | serverInfo: serverInfo, | 357 | serverInfo: serverInfo, |
| 281 | instructions: "This server provides access to Moqui ERP services and entities through MCP. Tools and resources are filtered based on your permissions." | 358 | instructions: "This server provides access to Moqui ERP services and entities through MCP. Tools and resources are filtered based on your permissions.", |
| 359 | sessionId: visit.visitId | ||
| 282 | ] | 360 | ] |
| 283 | 361 | ||
| 284 | ec.logger.info("MCP Initialize for user ${userId}: ${toolsResult?.result?.tools?.size() ?: 0} tools, ${resourcesResult?.result?.resources?.size() ?: 0} resources") | 362 | ec.logger.info("MCP Initialize for user ${userId} (session ${sessionId}): ${toolsResult?.result?.tools?.size() ?: 0} tools, ${resourcesResult?.result?.resources?.size() ?: 0} resources") |
| 285 | ]]></script> | 363 | ]]></script> |
| 286 | </actions> | 364 | </actions> |
| 287 | </service> | 365 | </service> |
| 288 | 366 | ||
| 289 | <service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60"> | 367 | <service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60" authz-require="false"> |
| 290 | <description>Handle MCP tools/list request with direct Moqui service discovery</description> | 368 | <description>Handle MCP tools/list request with direct Moqui service discovery</description> |
| 291 | <in-parameters> | 369 | <in-parameters> |
| 370 | <parameter name="sessionId"/> | ||
| 292 | <parameter name="cursor"/> | 371 | <parameter name="cursor"/> |
| 293 | </in-parameters> | 372 | </in-parameters> |
| 294 | <out-parameters> | 373 | <out-parameters> |
| ... | @@ -301,32 +380,40 @@ | ... | @@ -301,32 +380,40 @@ |
| 301 | 380 | ||
| 302 | // ec is already available from context | 381 | // ec is already available from context |
| 303 | 382 | ||
| 304 | // Use curated list of safe, commonly-used services plus some simple MCP-specific ones | 383 | // Validate session if provided |
| 305 | def safeServiceNames = [ | 384 | if (sessionId) { |
| 306 | "McpServices.mcp#Ping" | 385 | def visit = ec.entity.find("moqui.server.Visit") |
| 307 | ] | 386 | .condition("visitId", sessionId) |
| 387 | .one() | ||
| 308 | 388 | ||
| 309 | def availableTools = [] | 389 | if (!visit || visit.userId != ec.user.userId) { |
| 310 | ec.logger.info("MCP ToolsList: Checking ${safeServiceNames.size()} services for user ${ec.user.userId}") | 390 | throw new Exception("Invalid session: ${sessionId}") |
| 391 | } | ||
| 311 | 392 | ||
| 312 | // Convert safe services to MCP tools | 393 | // Update session activity |
| 313 | for (serviceName in safeServiceNames) { | 394 | def metadata = [:] |
| 314 | try { | 395 | try { |
| 315 | def serviceDef = ec.service.getServiceDefinition(serviceName) | 396 | metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map |
| 316 | if (!serviceDef) { | 397 | } catch (Exception e) { |
| 317 | ec.logger.info("MCP ToolsList: Service ${serviceName} not found") | 398 | ec.logger.debug("Failed to parse Visit metadata: ${e.message}") |
| 318 | continue | ||
| 319 | } | 399 | } |
| 320 | 400 | ||
| 321 | // Check permission using Moqui's artifact authorization | 401 | metadata.mcpLastActivity = System.currentTimeMillis() |
| 322 | boolean hasPermission = ec.user.hasPermission(serviceName) | 402 | metadata.mcpLastOperation = "tools/list" |
| 323 | ec.logger.info("MCP ToolsList: Service ${serviceName} hasPermission=${hasPermission}") | 403 | visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) |
| 324 | if (!hasPermission) { | 404 | visit.update() |
| 325 | continue | ||
| 326 | } | 405 | } |
| 327 | 406 | ||
| 407 | // Discover all services the user has permission to access | ||
| 408 | def availableTools = [] | ||
| 409 | def allServiceNames = ec.service.getKnownServiceNames() | ||
| 410 | ec.logger.info("MCP ToolsList: Checking ${allServiceNames.size()} services for user ${ec.user.userId}${sessionId ? ' (session: ' + sessionId + ')' : ''}") | ||
| 411 | |||
| 412 | // Helper function to convert service to MCP tool | ||
| 413 | def convertServiceToTool = { serviceName -> | ||
| 414 | try { | ||
| 328 | def serviceDefinition = ec.service.getServiceDefinition(serviceName) | 415 | def serviceDefinition = ec.service.getServiceDefinition(serviceName) |
| 329 | if (!serviceDefinition) continue | 416 | if (!serviceDefinition) return null |
| 330 | 417 | ||
| 331 | def serviceNode = serviceDefinition.serviceNode | 418 | def serviceNode = serviceDefinition.serviceNode |
| 332 | 419 | ||
| ... | @@ -361,7 +448,6 @@ | ... | @@ -361,7 +448,6 @@ |
| 361 | } | 448 | } |
| 362 | 449 | ||
| 363 | // Convert Moqui type to JSON Schema type | 450 | // Convert Moqui type to JSON Schema type |
| 364 | // Convert Moqui type to JSON Schema type | ||
| 365 | def typeMap = [ | 451 | def typeMap = [ |
| 366 | "text-short": "string", | 452 | "text-short": "string", |
| 367 | "text-medium": "string", | 453 | "text-medium": "string", |
| ... | @@ -390,10 +476,44 @@ | ... | @@ -390,10 +476,44 @@ |
| 390 | } | 476 | } |
| 391 | } | 477 | } |
| 392 | 478 | ||
| 479 | return tool | ||
| 480 | } catch (Exception e) { | ||
| 481 | ec.logger.warn("Error converting service ${serviceName} to tool: ${e.message}") | ||
| 482 | return null | ||
| 483 | } | ||
| 484 | } | ||
| 485 | |||
| 486 | // Add specific MCP services that should be exposed as tools | ||
| 487 | def mcpToolServices = ["McpServices.mcp#Ping"] | ||
| 488 | for (serviceName in mcpToolServices) { | ||
| 489 | boolean hasPermission = ec.user.hasPermission(serviceName) | ||
| 490 | ec.logger.info("MCP ToolsList: MCP service ${serviceName} hasPermission=${hasPermission}") | ||
| 491 | if (!hasPermission) { | ||
| 492 | continue | ||
| 493 | } | ||
| 494 | |||
| 495 | def tool = convertServiceToTool(serviceName) | ||
| 496 | if (tool) { | ||
| 393 | availableTools << tool | 497 | availableTools << tool |
| 498 | } | ||
| 499 | } | ||
| 394 | 500 | ||
| 395 | } catch (Exception e) { | 501 | // Now add all other services the user has permission to access |
| 396 | ec.logger.warn("Error processing service ${serviceName}: ${e.message}") | 502 | for (serviceName in allServiceNames) { |
| 503 | // Skip internal MCP services to avoid recursion (already handled above) | ||
| 504 | if (serviceName.startsWith("McpServices.")) { | ||
| 505 | continue | ||
| 506 | } | ||
| 507 | |||
| 508 | // Check permission using Moqui's artifact authorization | ||
| 509 | boolean hasPermission = ec.user.hasPermission(serviceName) | ||
| 510 | if (!hasPermission) { | ||
| 511 | continue | ||
| 512 | } | ||
| 513 | |||
| 514 | def tool = convertServiceToTool(serviceName) | ||
| 515 | if (tool) { | ||
| 516 | availableTools << tool | ||
| 397 | } | 517 | } |
| 398 | } | 518 | } |
| 399 | 519 | ||
| ... | @@ -407,7 +527,7 @@ | ... | @@ -407,7 +527,7 @@ |
| 407 | </actions> | 527 | </actions> |
| 408 | </service> | 528 | </service> |
| 409 | 529 | ||
| 410 | <service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300"> | 530 | <service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300" authz-require="false"> |
| 411 | <description>Handle MCP tools/call request with direct Moqui service execution</description> | 531 | <description>Handle MCP tools/call request with direct Moqui service execution</description> |
| 412 | <in-parameters> | 532 | <in-parameters> |
| 413 | <parameter name="name" required="true"/> | 533 | <parameter name="name" required="true"/> |
| ... | @@ -496,9 +616,10 @@ | ... | @@ -496,9 +616,10 @@ |
| 496 | </actions> | 616 | </actions> |
| 497 | </service> | 617 | </service> |
| 498 | 618 | ||
| 499 | <service verb="mcp" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60"> | 619 | <service verb="mcp" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60" authz-require="false"> |
| 500 | <description>Handle MCP resources/list request with Moqui entity discovery</description> | 620 | <description>Handle MCP resources/list request with Moqui entity discovery</description> |
| 501 | <in-parameters> | 621 | <in-parameters> |
| 622 | <parameter name="sessionId"/> | ||
| 502 | <parameter name="cursor"/> | 623 | <parameter name="cursor"/> |
| 503 | </in-parameters> | 624 | </in-parameters> |
| 504 | <out-parameters> | 625 | <out-parameters> |
| ... | @@ -510,6 +631,30 @@ | ... | @@ -510,6 +631,30 @@ |
| 510 | 631 | ||
| 511 | ExecutionContext ec = context.ec | 632 | ExecutionContext ec = context.ec |
| 512 | 633 | ||
| 634 | // Validate session if provided | ||
| 635 | if (sessionId) { | ||
| 636 | def visit = ec.entity.find("moqui.server.Visit") | ||
| 637 | .condition("visitId", sessionId) | ||
| 638 | .one() | ||
| 639 | |||
| 640 | if (!visit || visit.userId != ec.user.userId) { | ||
| 641 | throw new Exception("Invalid session: ${sessionId}") | ||
| 642 | } | ||
| 643 | |||
| 644 | // Update session activity | ||
| 645 | def metadata = [:] | ||
| 646 | try { | ||
| 647 | metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map | ||
| 648 | } catch (Exception e) { | ||
| 649 | ec.logger.debug("Failed to parse Visit metadata: ${e.message}") | ||
| 650 | } | ||
| 651 | |||
| 652 | metadata.mcpLastActivity = System.currentTimeMillis() | ||
| 653 | metadata.mcpLastOperation = "resources/list" | ||
| 654 | visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) | ||
| 655 | visit.update() | ||
| 656 | } | ||
| 657 | |||
| 513 | // Use curated list of commonly used entities instead of discovering all entities | 658 | // Use curated list of commonly used entities instead of discovering all entities |
| 514 | def safeEntityNames = [ | 659 | def safeEntityNames = [ |
| 515 | "moqui.basic.UserAccount", | 660 | "moqui.basic.UserAccount", |
| ... | @@ -567,9 +712,10 @@ | ... | @@ -567,9 +712,10 @@ |
| 567 | </actions> | 712 | </actions> |
| 568 | </service> | 713 | </service> |
| 569 | 714 | ||
| 570 | <service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120"> | 715 | <service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120" authz-require="false"> |
| 571 | <description>Handle MCP resources/read request with Moqui entity queries</description> | 716 | <description>Handle MCP resources/read request with Moqui entity queries</description> |
| 572 | <in-parameters> | 717 | <in-parameters> |
| 718 | <parameter name="sessionId"/> | ||
| 573 | <parameter name="uri" required="true"/> | 719 | <parameter name="uri" required="true"/> |
| 574 | </in-parameters> | 720 | </in-parameters> |
| 575 | <out-parameters> | 721 | <out-parameters> |
| ... | @@ -582,6 +728,31 @@ | ... | @@ -582,6 +728,31 @@ |
| 582 | 728 | ||
| 583 | ExecutionContext ec = context.ec | 729 | ExecutionContext ec = context.ec |
| 584 | 730 | ||
| 731 | // Validate session if provided | ||
| 732 | if (sessionId) { | ||
| 733 | def visit = ec.entity.find("moqui.server.Visit") | ||
| 734 | .condition("visitId", sessionId) | ||
| 735 | .one() | ||
| 736 | |||
| 737 | if (!visit || visit.userId != ec.user.userId) { | ||
| 738 | throw new Exception("Invalid session: ${sessionId}") | ||
| 739 | } | ||
| 740 | |||
| 741 | // Update session activity | ||
| 742 | def metadata = [:] | ||
| 743 | try { | ||
| 744 | metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map | ||
| 745 | } catch (Exception e) { | ||
| 746 | ec.logger.debug("Failed to parse Visit metadata: ${e.message}") | ||
| 747 | } | ||
| 748 | |||
| 749 | metadata.mcpLastActivity = System.currentTimeMillis() | ||
| 750 | metadata.mcpLastOperation = "resources/read" | ||
| 751 | metadata.mcpLastResource = uri | ||
| 752 | visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) | ||
| 753 | visit.update() | ||
| 754 | } | ||
| 755 | |||
| 585 | // Parse entity URI (format: entity://EntityName) | 756 | // Parse entity URI (format: entity://EntityName) |
| 586 | if (!uri.startsWith("entity://")) { | 757 | if (!uri.startsWith("entity://")) { |
| 587 | throw new Exception("Invalid resource URI: ${uri}") | 758 | throw new Exception("Invalid resource URI: ${uri}") |
| ... | @@ -674,18 +845,46 @@ | ... | @@ -674,18 +845,46 @@ |
| 674 | </actions> | 845 | </actions> |
| 675 | </service> | 846 | </service> |
| 676 | 847 | ||
| 677 | <service verb="mcp" noun="Ping" authenticate="true" allow-remote="true" transaction-timeout="10"> | 848 | <service verb="mcp" noun="Ping" authenticate="true" allow-remote="true" transaction-timeout="10" authz-require="false"> |
| 678 | <description>Handle MCP ping request for health check</description> | 849 | <description>Handle MCP ping request for health check</description> |
| 679 | <in-parameters/> | 850 | <in-parameters> |
| 851 | <parameter name="sessionId"/> | ||
| 852 | </in-parameters> | ||
| 680 | <out-parameters> | 853 | <out-parameters> |
| 681 | <parameter name="result" type="Map"/> | 854 | <parameter name="result" type="Map"/> |
| 682 | </out-parameters> | 855 | </out-parameters> |
| 683 | <actions> | 856 | <actions> |
| 684 | <script><![CDATA[ | 857 | <script><![CDATA[ |
| 858 | // Validate session if provided | ||
| 859 | if (sessionId) { | ||
| 860 | def visit = ec.entity.find("moqui.server.Visit") | ||
| 861 | .condition("visitId", sessionId) | ||
| 862 | .one() | ||
| 863 | |||
| 864 | if (!visit || visit.userId != ec.user.userId) { | ||
| 865 | throw new Exception("Invalid session: ${sessionId}") | ||
| 866 | } | ||
| 867 | |||
| 868 | // Update session activity | ||
| 869 | def metadata = [:] | ||
| 870 | try { | ||
| 871 | metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map | ||
| 872 | } catch (Exception e) { | ||
| 873 | ec.logger.debug("Failed to parse Visit metadata: ${e.message}") | ||
| 874 | } | ||
| 875 | |||
| 876 | metadata.mcpLastActivity = System.currentTimeMillis() | ||
| 877 | metadata.mcpLastOperation = "ping" | ||
| 878 | visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) | ||
| 879 | visit.update() | ||
| 880 | } | ||
| 881 | |||
| 685 | result = [ | 882 | result = [ |
| 686 | timestamp: ec.user.getNowTimestamp(), | 883 | timestamp: ec.user.getNowTimestamp(), |
| 687 | status: "healthy", | 884 | status: "healthy", |
| 688 | version: "2.0.0" | 885 | version: "2.0.0", |
| 886 | sessionId: sessionId, | ||
| 887 | architecture: "Visit-based sessions" | ||
| 689 | ] | 888 | ] |
| 690 | ]]></script> | 889 | ]]></script> |
| 691 | </actions> | 890 | </actions> | ... | ... |
| ... | @@ -81,8 +81,8 @@ class EnhancedMcpServlet extends HttpServlet { | ... | @@ -81,8 +81,8 @@ class EnhancedMcpServlet extends HttpServlet { |
| 81 | 81 | ||
| 82 | private JsonSlurper jsonSlurper = new JsonSlurper() | 82 | private JsonSlurper jsonSlurper = new JsonSlurper() |
| 83 | 83 | ||
| 84 | // Session management using dedicated session manager | 84 | // Session management using Moqui's Visit system directly |
| 85 | private final McpSessionManager sessionManager = new McpSessionManager() | 85 | // No need for separate session manager - Visit entity handles persistence |
| 86 | 86 | ||
| 87 | @Override | 87 | @Override |
| 88 | void init(ServletConfig config) throws ServletException { | 88 | void init(ServletConfig config) throws ServletException { |
| ... | @@ -219,11 +219,6 @@ try { | ... | @@ -219,11 +219,6 @@ try { |
| 219 | private void handleSseConnection(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec) | 219 | private void handleSseConnection(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec) |
| 220 | throws IOException { | 220 | throws IOException { |
| 221 | 221 | ||
| 222 | if (sessionManager.isShuttingDown()) { | ||
| 223 | response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down") | ||
| 224 | return | ||
| 225 | } | ||
| 226 | |||
| 227 | logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") | 222 | logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") |
| 228 | 223 | ||
| 229 | // Enable async support for SSE | 224 | // Enable async support for SSE |
| ... | @@ -239,40 +234,45 @@ try { | ... | @@ -239,40 +234,45 @@ try { |
| 239 | response.setHeader("Access-Control-Allow-Origin", "*") | 234 | response.setHeader("Access-Control-Allow-Origin", "*") |
| 240 | response.setHeader("X-Accel-Buffering", "no") // Disable nginx buffering | 235 | response.setHeader("X-Accel-Buffering", "no") // Disable nginx buffering |
| 241 | 236 | ||
| 242 | String sessionId = UUID.randomUUID().toString() | 237 | // Get or create Visit (Moqui automatically creates Visit) |
| 243 | String visitId = ec.user.getVisitId() | 238 | def visit = ec.user.getVisit() |
| 239 | if (!visit) { | ||
| 240 | response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to create Visit") | ||
| 241 | return | ||
| 242 | } | ||
| 244 | 243 | ||
| 245 | // Create Visit-based session transport | 244 | // Create Visit-based session transport |
| 246 | VisitBasedMcpSession session = new VisitBasedMcpSession(sessionId, visitId, response.writer, ec) | 245 | VisitBasedMcpSession session = new VisitBasedMcpSession(visit, response.writer, ec) |
| 247 | sessionManager.registerSession(session) | ||
| 248 | 246 | ||
| 249 | try { | 247 | try { |
| 250 | // Send initial connection event | 248 | // Send initial connection event |
| 251 | def connectData = [ | 249 | def connectData = [ |
| 252 | type: "connected", | 250 | type: "connected", |
| 253 | sessionId: sessionId, | 251 | sessionId: visit.visitId, |
| 254 | timestamp: System.currentTimeMillis(), | 252 | timestamp: System.currentTimeMillis(), |
| 255 | serverInfo: [ | 253 | serverInfo: [ |
| 256 | name: "Moqui MCP SSE Server", | 254 | name: "Moqui MCP SSE Server", |
| 257 | version: "2.0.0", | 255 | version: "2.0.0", |
| 258 | protocolVersion: "2025-06-18" | 256 | protocolVersion: "2025-06-18", |
| 257 | architecture: "Visit-based sessions" | ||
| 259 | ] | 258 | ] |
| 260 | ] | 259 | ] |
| 261 | sendSseEvent(response.writer, "connect", groovy.json.JsonOutput.toJson(connectData), 0) | 260 | sendSseEvent(response.writer, "connect", groovy.json.JsonOutput.toJson(connectData), 0) |
| 262 | 261 | ||
| 263 | // Send endpoint info for message posting | 262 | // Send endpoint info for message posting |
| 264 | sendSseEvent(response.writer, "endpoint", "/mcp/message?sessionId=" + sessionId, 1) | 263 | sendSseEvent(response.writer, "endpoint", "/mcp/message?sessionId=" + visit.visitId, 1) |
| 265 | 264 | ||
| 266 | // Keep connection alive with periodic pings | 265 | // Keep connection alive with periodic pings |
| 267 | int pingCount = 0 | 266 | int pingCount = 0 |
| 268 | while (!response.isCommitted() && !sessionManager.isShuttingDown() && pingCount < 60) { // 5 minutes max | 267 | while (!response.isCommitted() && pingCount < 60) { // 5 minutes max |
| 269 | Thread.sleep(5000) // Wait 5 seconds | 268 | Thread.sleep(5000) // Wait 5 seconds |
| 270 | 269 | ||
| 271 | if (!response.isCommitted() && !sessionManager.isShuttingDown()) { | 270 | if (!response.isCommitted()) { |
| 272 | def pingData = [ | 271 | def pingData = [ |
| 273 | type: "ping", | 272 | type: "ping", |
| 274 | timestamp: System.currentTimeMillis(), | 273 | timestamp: System.currentTimeMillis(), |
| 275 | connections: sessionManager.getActiveSessionCount() | 274 | sessionId: visit.visitId, |
| 275 | architecture: "Visit-based sessions" | ||
| 276 | ] | 276 | ] |
| 277 | sendSseEvent(response.writer, "ping", groovy.json.JsonOutput.toJson(pingData), pingCount + 2) | 277 | sendSseEvent(response.writer, "ping", groovy.json.JsonOutput.toJson(pingData), pingCount + 2) |
| 278 | pingCount++ | 278 | pingCount++ |
| ... | @@ -280,16 +280,16 @@ try { | ... | @@ -280,16 +280,16 @@ try { |
| 280 | } | 280 | } |
| 281 | 281 | ||
| 282 | } catch (InterruptedException e) { | 282 | } catch (InterruptedException e) { |
| 283 | logger.info("SSE connection interrupted for session ${sessionId}") | 283 | logger.info("SSE connection interrupted for session ${visit.visitId}") |
| 284 | Thread.currentThread().interrupt() | 284 | Thread.currentThread().interrupt() |
| 285 | } catch (Exception e) { | 285 | } catch (Exception e) { |
| 286 | logger.warn("Enhanced SSE connection error: ${e.message}", e) | 286 | logger.warn("Enhanced SSE connection error: ${e.message}", e) |
| 287 | } finally { | 287 | } finally { |
| 288 | // Clean up session | 288 | // Clean up session - Visit persistence handles cleanup automatically |
| 289 | sessionManager.unregisterSession(sessionId) | ||
| 290 | try { | 289 | try { |
| 291 | def closeData = [ | 290 | def closeData = [ |
| 292 | type: "disconnected", | 291 | type: "disconnected", |
| 292 | sessionId: visit.visitId, | ||
| 293 | timestamp: System.currentTimeMillis() | 293 | timestamp: System.currentTimeMillis() |
| 294 | ] | 294 | ] |
| 295 | sendSseEvent(response.writer, "disconnect", groovy.json.JsonOutput.toJson(closeData), -1) | 295 | sendSseEvent(response.writer, "disconnect", groovy.json.JsonOutput.toJson(closeData), -1) |
| ... | @@ -311,11 +311,6 @@ try { | ... | @@ -311,11 +311,6 @@ try { |
| 311 | private void handleMessage(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec) | 311 | private void handleMessage(HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl ec) |
| 312 | throws IOException { | 312 | throws IOException { |
| 313 | 313 | ||
| 314 | if (sessionManager.isShuttingDown()) { | ||
| 315 | response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down") | ||
| 316 | return | ||
| 317 | } | ||
| 318 | |||
| 319 | // Get sessionId from request parameter or header | 314 | // Get sessionId from request parameter or header |
| 320 | String sessionId = request.getParameter("sessionId") ?: request.getHeader("Mcp-Session-Id") | 315 | String sessionId = request.getParameter("sessionId") ?: request.getHeader("Mcp-Session-Id") |
| 321 | if (!sessionId) { | 316 | if (!sessionId) { |
| ... | @@ -324,24 +319,42 @@ try { | ... | @@ -324,24 +319,42 @@ try { |
| 324 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) | 319 | response.setStatus(HttpServletResponse.SC_BAD_REQUEST) |
| 325 | response.writer.write(groovy.json.JsonOutput.toJson([ | 320 | response.writer.write(groovy.json.JsonOutput.toJson([ |
| 326 | error: "Missing sessionId parameter or header", | 321 | error: "Missing sessionId parameter or header", |
| 327 | activeSessions: sessionManager.getActiveSessionCount() | 322 | architecture: "Visit-based sessions" |
| 328 | ])) | 323 | ])) |
| 329 | return | 324 | return |
| 330 | } | 325 | } |
| 331 | 326 | ||
| 332 | // Get session from session manager | 327 | // Get Visit directly - this is our session |
| 333 | VisitBasedMcpSession session = sessionManager.getSession(sessionId) | 328 | def visit = ec.entity.find("moqui.server.Visit") |
| 334 | if (session == null) { | 329 | .condition("visitId", sessionId) |
| 330 | .one() | ||
| 331 | |||
| 332 | if (!visit) { | ||
| 335 | response.setContentType("application/json") | 333 | response.setContentType("application/json") |
| 336 | response.setCharacterEncoding("UTF-8") | 334 | response.setCharacterEncoding("UTF-8") |
| 337 | response.setStatus(HttpServletResponse.SC_NOT_FOUND) | 335 | response.setStatus(HttpServletResponse.SC_NOT_FOUND) |
| 338 | response.writer.write(groovy.json.JsonOutput.toJson([ | 336 | response.writer.write(groovy.json.JsonOutput.toJson([ |
| 339 | error: "Session not found: " + sessionId, | 337 | error: "Session not found: " + sessionId, |
| 340 | activeSessions: sessionManager.getActiveSessionCount() | 338 | architecture: "Visit-based sessions" |
| 341 | ])) | 339 | ])) |
| 342 | return | 340 | return |
| 343 | } | 341 | } |
| 344 | 342 | ||
| 343 | // Verify user has access to this Visit | ||
| 344 | if (visit.userId != ec.user.userId) { | ||
| 345 | response.setContentType("application/json") | ||
| 346 | response.setCharacterEncoding("UTF-8") | ||
| 347 | response.setStatus(HttpServletResponse.SC_FORBIDDEN) | ||
| 348 | response.writer.write(groovy.json.JsonOutput.toJson([ | ||
| 349 | error: "Access denied for session: " + sessionId, | ||
| 350 | architecture: "Visit-based sessions" | ||
| 351 | ])) | ||
| 352 | return | ||
| 353 | } | ||
| 354 | |||
| 355 | // Create session wrapper for this Visit | ||
| 356 | VisitBasedMcpSession session = new VisitBasedMcpSession(visit, response.writer, ec) | ||
| 357 | |||
| 345 | try { | 358 | try { |
| 346 | // Read request body | 359 | // Read request body |
| 347 | StringBuilder body = new StringBuilder() | 360 | StringBuilder body = new StringBuilder() |
| ... | @@ -407,8 +420,8 @@ try { | ... | @@ -407,8 +420,8 @@ try { |
| 407 | return | 420 | return |
| 408 | } | 421 | } |
| 409 | 422 | ||
| 410 | // Process the method | 423 | // Process method with session context |
| 411 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec) | 424 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, sessionId) |
| 412 | 425 | ||
| 413 | // Send response via MCP transport to the specific session | 426 | // Send response via MCP transport to the specific session |
| 414 | def responseMessage = new JsonRpcResponse(result, rpcRequest.id) | 427 | def responseMessage = new JsonRpcResponse(result, rpcRequest.id) |
| ... | @@ -420,7 +433,7 @@ try { | ... | @@ -420,7 +433,7 @@ try { |
| 420 | response.writer.write(groovy.json.JsonOutput.toJson([ | 433 | response.writer.write(groovy.json.JsonOutput.toJson([ |
| 421 | jsonrpc: "2.0", | 434 | jsonrpc: "2.0", |
| 422 | id: rpcRequest.id, | 435 | id: rpcRequest.id, |
| 423 | result: [status: "processed", sessionId: sessionId] | 436 | result: [status: "processed", sessionId: sessionId, architecture: "Visit-based"] |
| 424 | ])) | 437 | ])) |
| 425 | 438 | ||
| 426 | } catch (Exception e) { | 439 | } catch (Exception e) { |
| ... | @@ -518,8 +531,8 @@ try { | ... | @@ -518,8 +531,8 @@ try { |
| 518 | return | 531 | return |
| 519 | } | 532 | } |
| 520 | 533 | ||
| 521 | // Process MCP method using Moqui services | 534 | // Process MCP method using Moqui services (no sessionId in direct JSON-RPC) |
| 522 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec) | 535 | def result = processMcpMethod(rpcRequest.method, rpcRequest.params, ec, null) |
| 523 | 536 | ||
| 524 | // Build JSON-RPC response | 537 | // Build JSON-RPC response |
| 525 | def rpcResponse = [ | 538 | def rpcResponse = [ |
| ... | @@ -533,10 +546,13 @@ try { | ... | @@ -533,10 +546,13 @@ try { |
| 533 | response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse)) | 546 | response.writer.write(groovy.json.JsonOutput.toJson(rpcResponse)) |
| 534 | } | 547 | } |
| 535 | 548 | ||
| 536 | private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec) { | 549 | private Map<String, Object> processMcpMethod(String method, Map params, ExecutionContextImpl ec, String sessionId) { |
| 537 | logger.info("Enhanced METHOD: ${method} with params: ${params}") | 550 | logger.info("Enhanced METHOD: ${method} with params: ${params}, sessionId: ${sessionId}") |
| 538 | 551 | ||
| 539 | try { | 552 | try { |
| 553 | // Add session context to parameters for services | ||
| 554 | params.sessionId = sessionId | ||
| 555 | |||
| 540 | switch (method) { | 556 | switch (method) { |
| 541 | case "initialize": | 557 | case "initialize": |
| 542 | return callMcpService("mcp#Initialize", params, ec) | 558 | return callMcpService("mcp#Initialize", params, ec) |
| ... | @@ -552,16 +568,16 @@ try { | ... | @@ -552,16 +568,16 @@ try { |
| 552 | return callMcpService("mcp#ResourcesRead", params, ec) | 568 | return callMcpService("mcp#ResourcesRead", params, ec) |
| 553 | case "notifications/initialized": | 569 | case "notifications/initialized": |
| 554 | // Handle notification initialization - return success for now | 570 | // Handle notification initialization - return success for now |
| 555 | return [initialized: true] | 571 | return [initialized: true, sessionId: sessionId] |
| 556 | case "notifications/send": | 572 | case "notifications/send": |
| 557 | // Handle notification sending - return success for now | 573 | // Handle notification sending - return success for now |
| 558 | return [sent: true] | 574 | return [sent: true, sessionId: sessionId] |
| 559 | case "notifications/subscribe": | 575 | case "notifications/subscribe": |
| 560 | // Handle notification subscription - return success for now | 576 | // Handle notification subscription - return success for now |
| 561 | return [subscribed: true] | 577 | return [subscribed: true, sessionId: sessionId] |
| 562 | case "notifications/unsubscribe": | 578 | case "notifications/unsubscribe": |
| 563 | // Handle notification unsubscription - return success for now | 579 | // Handle notification unsubscription - return success for now |
| 564 | return [unsubscribed: true] | 580 | return [unsubscribed: true, sessionId: sessionId] |
| 565 | default: | 581 | default: |
| 566 | throw new IllegalArgumentException("Method not found: ${method}") | 582 | throw new IllegalArgumentException("Method not found: ${method}") |
| 567 | } | 583 | } |
| ... | @@ -580,10 +596,14 @@ try { | ... | @@ -580,10 +596,14 @@ try { |
| 580 | .call() | 596 | .call() |
| 581 | 597 | ||
| 582 | logger.info("Enhanced MCP service ${serviceName} result: ${result}") | 598 | logger.info("Enhanced MCP service ${serviceName} result: ${result}") |
| 583 | return result.result | 599 | if (result == null) { |
| 600 | logger.error("Enhanced MCP service ${serviceName} returned null result") | ||
| 601 | return [error: "Service returned null result"] | ||
| 602 | } | ||
| 603 | return result.result ?: [error: "Service result has no 'result' field"] | ||
| 584 | } catch (Exception e) { | 604 | } catch (Exception e) { |
| 585 | logger.error("Error calling Enhanced MCP service ${serviceName}", e) | 605 | logger.error("Error calling Enhanced MCP service ${serviceName}", e) |
| 586 | throw e | 606 | return [error: e.message] |
| 587 | } | 607 | } |
| 588 | } | 608 | } |
| 589 | 609 | ... | ... |
| ... | @@ -24,8 +24,13 @@ import java.util.concurrent.atomic.AtomicBoolean | ... | @@ -24,8 +24,13 @@ import java.util.concurrent.atomic.AtomicBoolean |
| 24 | 24 | ||
| 25 | /** | 25 | /** |
| 26 | * MCP Session Manager with SDK-style capabilities | 26 | * MCP Session Manager with SDK-style capabilities |
| 27 | * | ||
| 28 | * @deprecated This class is deprecated. Use Moqui's Visit entity directly for session management. | ||
| 29 | * See VisitBasedMcpSession for the new Visit-based approach. | ||
| 30 | * | ||
| 27 | * Provides centralized session management, broadcasting, and graceful shutdown | 31 | * Provides centralized session management, broadcasting, and graceful shutdown |
| 28 | */ | 32 | */ |
| 33 | @Deprecated | ||
| 29 | class McpSessionManager { | 34 | class McpSessionManager { |
| 30 | protected final static Logger logger = LoggerFactory.getLogger(McpSessionManager.class) | 35 | protected final static Logger logger = LoggerFactory.getLogger(McpSessionManager.class) |
| 31 | 36 | ... | ... |
| ... | @@ -15,67 +15,59 @@ package org.moqui.mcp | ... | @@ -15,67 +15,59 @@ package org.moqui.mcp |
| 15 | 15 | ||
| 16 | import org.moqui.context.ExecutionContext | 16 | import org.moqui.context.ExecutionContext |
| 17 | import org.moqui.impl.context.ExecutionContextImpl | 17 | import org.moqui.impl.context.ExecutionContextImpl |
| 18 | import org.moqui.entity.EntityValue | ||
| 18 | import org.slf4j.Logger | 19 | import org.slf4j.Logger |
| 19 | import org.slf4j.LoggerFactory | 20 | import org.slf4j.LoggerFactory |
| 20 | 21 | ||
| 21 | import java.util.concurrent.ConcurrentHashMap | ||
| 22 | import java.util.concurrent.atomic.AtomicBoolean | 22 | import java.util.concurrent.atomic.AtomicBoolean |
| 23 | import java.util.concurrent.atomic.AtomicLong | 23 | import java.util.concurrent.atomic.AtomicLong |
| 24 | 24 | ||
| 25 | /** | 25 | /** |
| 26 | * MCP Session implementation that integrates with Moqui's Visit system | 26 | * MCP Session implementation that uses Moqui's Visit entity directly |
| 27 | * Provides SDK-style session management while leveraging Moqui's built-in tracking | 27 | * Eliminates custom session management by leveraging Moqui's built-in Visit system |
| 28 | */ | 28 | */ |
| 29 | class VisitBasedMcpSession implements MoquiMcpTransport { | 29 | class VisitBasedMcpSession implements MoquiMcpTransport { |
| 30 | protected final static Logger logger = LoggerFactory.getLogger(VisitBasedMcpSession.class) | 30 | protected final static Logger logger = LoggerFactory.getLogger(VisitBasedMcpSession.class) |
| 31 | 31 | ||
| 32 | private final String sessionId | 32 | private final EntityValue visit // The Visit entity record |
| 33 | private final String visitId | ||
| 34 | private final PrintWriter writer | 33 | private final PrintWriter writer |
| 35 | private final ExecutionContextImpl ec | 34 | private final ExecutionContextImpl ec |
| 36 | private final AtomicBoolean active = new AtomicBoolean(true) | 35 | private final AtomicBoolean active = new AtomicBoolean(true) |
| 37 | private final AtomicBoolean closing = new AtomicBoolean(false) | 36 | private final AtomicBoolean closing = new AtomicBoolean(false) |
| 38 | private final AtomicLong messageCount = new AtomicLong(0) | 37 | private final AtomicLong messageCount = new AtomicLong(0) |
| 39 | private final Date createdAt | ||
| 40 | 38 | ||
| 41 | // MCP session metadata stored in Visit context | 39 | VisitBasedMcpSession(EntityValue visit, PrintWriter writer, ExecutionContextImpl ec) { |
| 42 | private final Map<String, Object> sessionMetadata = new ConcurrentHashMap<>() | 40 | this.visit = visit |
| 43 | |||
| 44 | VisitBasedMcpSession(String sessionId, String visitId, PrintWriter writer, ExecutionContextImpl ec) { | ||
| 45 | this.sessionId = sessionId | ||
| 46 | this.visitId = visitId | ||
| 47 | this.writer = writer | 41 | this.writer = writer |
| 48 | this.ec = ec | 42 | this.ec = ec |
| 49 | this.createdAt = new Date() | ||
| 50 | 43 | ||
| 51 | // Initialize session metadata in Visit context | 44 | // Initialize MCP session in Visit if not already done |
| 52 | initializeSessionMetadata() | 45 | initializeMcpSession() |
| 53 | } | 46 | } |
| 54 | 47 | ||
| 55 | private void initializeSessionMetadata() { | 48 | private void initializeMcpSession() { |
| 56 | try { | 49 | try { |
| 57 | // Store MCP session info in Visit context for persistence | 50 | def metadata = getSessionMetadata() |
| 58 | if (visitId && ec) { | 51 | if (!metadata.mcpSession) { |
| 59 | def visit = ec.entity.find("moqui.server.Visit").condition("visitId", visitId).one() | 52 | // Mark this Visit as an MCP session |
| 60 | if (visit) { | 53 | metadata.mcpSession = true |
| 61 | // Store MCP session metadata as JSON in Visit's context or a separate field | 54 | metadata.mcpProtocolVersion = "2025-06-18" |
| 62 | sessionMetadata.put("mcpSessionId", sessionId) | 55 | metadata.mcpCreatedAt = System.currentTimeMillis() |
| 63 | sessionMetadata.put("mcpCreatedAt", createdAt.time) | 56 | metadata.mcpTransportType = "SSE" |
| 64 | sessionMetadata.put("mcpProtocolVersion", "2025-06-18") | 57 | metadata.mcpMessageCount = 0 |
| 65 | sessionMetadata.put("mcpTransportType", "SSE") | 58 | saveSessionMetadata(metadata) |
| 66 | 59 | ||
| 67 | logger.info("MCP Session ${sessionId} initialized with Visit ${visitId}") | 60 | logger.info("MCP Session initialized for Visit ${visit.visitId}") |
| 68 | } | ||
| 69 | } | 61 | } |
| 70 | } catch (Exception e) { | 62 | } catch (Exception e) { |
| 71 | logger.warn("Failed to initialize session metadata for Visit ${visitId}: ${e.message}") | 63 | logger.warn("Failed to initialize MCP session for Visit ${visit.visitId}: ${e.message}") |
| 72 | } | 64 | } |
| 73 | } | 65 | } |
| 74 | 66 | ||
| 75 | @Override | 67 | @Override |
| 76 | void sendMessage(JsonRpcMessage message) { | 68 | void sendMessage(JsonRpcMessage message) { |
| 77 | if (!active.get() || closing.get()) { | 69 | if (!active.get() || closing.get()) { |
| 78 | logger.warn("Attempted to send message on inactive or closing session ${sessionId}") | 70 | logger.warn("Attempted to send message on inactive or closing session ${visit.visitId}") |
| 79 | return | 71 | return |
| 80 | } | 72 | } |
| 81 | 73 | ||
| ... | @@ -88,7 +80,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -88,7 +80,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 88 | updateSessionActivity() | 80 | updateSessionActivity() |
| 89 | 81 | ||
| 90 | } catch (Exception e) { | 82 | } catch (Exception e) { |
| 91 | logger.error("Failed to send message on session ${sessionId}: ${e.message}") | 83 | logger.error("Failed to send message on session ${visit.visitId}: ${e.message}") |
| 92 | if (e.message?.contains("disconnected") || e.message?.contains("Client disconnected")) { | 84 | if (e.message?.contains("disconnected") || e.message?.contains("Client disconnected")) { |
| 93 | close() | 85 | close() |
| 94 | } | 86 | } |
| ... | @@ -101,12 +93,12 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -101,12 +93,12 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 101 | } | 93 | } |
| 102 | 94 | ||
| 103 | closing.set(true) | 95 | closing.set(true) |
| 104 | logger.info("Gracefully closing MCP session ${sessionId}") | 96 | logger.info("Gracefully closing MCP session ${visit.visitId}") |
| 105 | 97 | ||
| 106 | try { | 98 | try { |
| 107 | // Send graceful shutdown notification | 99 | // Send graceful shutdown notification |
| 108 | def shutdownMessage = new JsonRpcNotification("shutdown", [ | 100 | def shutdownMessage = new JsonRpcNotification("shutdown", [ |
| 109 | sessionId: sessionId, | 101 | sessionId: visit.visitId, |
| 110 | timestamp: System.currentTimeMillis() | 102 | timestamp: System.currentTimeMillis() |
| 111 | ]) | 103 | ]) |
| 112 | sendMessage(shutdownMessage) | 104 | sendMessage(shutdownMessage) |
| ... | @@ -115,7 +107,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -115,7 +107,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 115 | Thread.sleep(100) | 107 | Thread.sleep(100) |
| 116 | 108 | ||
| 117 | } catch (Exception e) { | 109 | } catch (Exception e) { |
| 118 | logger.warn("Error during graceful shutdown of session ${sessionId}: ${e.message}") | 110 | logger.warn("Error during graceful shutdown of session ${visit.visitId}: ${e.message}") |
| 119 | } finally { | 111 | } finally { |
| 120 | close() | 112 | close() |
| 121 | } | 113 | } |
| ... | @@ -126,7 +118,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -126,7 +118,7 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 126 | return // Already closed | 118 | return // Already closed |
| 127 | } | 119 | } |
| 128 | 120 | ||
| 129 | logger.info("Closing MCP session ${sessionId} (messages sent: ${messageCount.get()})") | 121 | logger.info("Closing MCP session ${visit.visitId} (messages sent: ${messageCount.get()})") |
| 130 | 122 | ||
| 131 | try { | 123 | try { |
| 132 | // Update Visit with session end info | 124 | // Update Visit with session end info |
| ... | @@ -136,14 +128,14 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -136,14 +128,14 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 136 | if (writer && !writer.checkError()) { | 128 | if (writer && !writer.checkError()) { |
| 137 | sendSseEvent("close", groovy.json.JsonOutput.toJson([ | 129 | sendSseEvent("close", groovy.json.JsonOutput.toJson([ |
| 138 | type: "disconnected", | 130 | type: "disconnected", |
| 139 | sessionId: sessionId, | 131 | sessionId: visit.visitId, |
| 140 | messageCount: messageCount.get(), | 132 | messageCount: messageCount.get(), |
| 141 | timestamp: System.currentTimeMillis() | 133 | timestamp: System.currentTimeMillis() |
| 142 | ])) | 134 | ])) |
| 143 | } | 135 | } |
| 144 | 136 | ||
| 145 | } catch (Exception e) { | 137 | } catch (Exception e) { |
| 146 | logger.warn("Error during session close ${sessionId}: ${e.message}") | 138 | logger.warn("Error during session close ${visit.visitId}: ${e.message}") |
| 147 | } | 139 | } |
| 148 | } | 140 | } |
| 149 | 141 | ||
| ... | @@ -154,11 +146,15 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -154,11 +146,15 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 154 | 146 | ||
| 155 | @Override | 147 | @Override |
| 156 | String getSessionId() { | 148 | String getSessionId() { |
| 157 | return sessionId | 149 | return visit.visitId |
| 158 | } | 150 | } |
| 159 | 151 | ||
| 160 | String getVisitId() { | 152 | String getVisitId() { |
| 161 | return visitId | 153 | return visit.visitId |
| 154 | } | ||
| 155 | |||
| 156 | EntityValue getVisit() { | ||
| 157 | return visit | ||
| 162 | } | 158 | } |
| 163 | 159 | ||
| 164 | /** | 160 | /** |
| ... | @@ -166,13 +162,13 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -166,13 +162,13 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 166 | */ | 162 | */ |
| 167 | Map getSessionStats() { | 163 | Map getSessionStats() { |
| 168 | return [ | 164 | return [ |
| 169 | sessionId: sessionId, | 165 | sessionId: visit.visitId, |
| 170 | visitId: visitId, | 166 | visitId: visit.visitId, |
| 171 | createdAt: createdAt, | 167 | createdAt: visit.fromDate, |
| 172 | messageCount: messageCount.get(), | 168 | messageCount: messageCount.get(), |
| 173 | active: active.get(), | 169 | active: active.get(), |
| 174 | closing: closing.get(), | 170 | closing: closing.get(), |
| 175 | duration: System.currentTimeMillis() - createdAt.time | 171 | duration: System.currentTimeMillis() - visit.fromDate.time |
| 176 | ] | 172 | ] |
| 177 | } | 173 | } |
| 178 | 174 | ||
| ... | @@ -198,18 +194,16 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -198,18 +194,16 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 198 | */ | 194 | */ |
| 199 | private void updateSessionActivity() { | 195 | private void updateSessionActivity() { |
| 200 | try { | 196 | try { |
| 201 | if (visitId && ec) { | 197 | if (visit && ec) { |
| 202 | // Update Visit with latest activity | 198 | // Update Visit with latest activity |
| 203 | ec.service.sync().name("update", "moqui.server.Visit") | 199 | visit.thruDate = ec.user.getNowTimestamp() |
| 204 | .parameters([ | 200 | visit.update() |
| 205 | visitId: visitId, | 201 | |
| 206 | thruDate: ec.user.getNowTimestamp() | 202 | // Update MCP-specific activity in metadata |
| 207 | ]) | 203 | def metadata = getSessionMetadata() |
| 208 | .call() | 204 | metadata.mcpLastActivity = System.currentTimeMillis() |
| 209 | 205 | metadata.mcpMessageCount = messageCount.get() | |
| 210 | // Could also update a custom field for MCP-specific activity | 206 | saveSessionMetadata(metadata) |
| 211 | sessionMetadata.put("mcpLastActivity", System.currentTimeMillis()) | ||
| 212 | sessionMetadata.put("mcpMessageCount", messageCount.get()) | ||
| 213 | } | 207 | } |
| 214 | } catch (Exception e) { | 208 | } catch (Exception e) { |
| 215 | logger.debug("Failed to update session activity: ${e.message}") | 209 | logger.debug("Failed to update session activity: ${e.message}") |
| ... | @@ -221,37 +215,57 @@ class VisitBasedMcpSession implements MoquiMcpTransport { | ... | @@ -221,37 +215,57 @@ class VisitBasedMcpSession implements MoquiMcpTransport { |
| 221 | */ | 215 | */ |
| 222 | private void updateSessionEnd() { | 216 | private void updateSessionEnd() { |
| 223 | try { | 217 | try { |
| 224 | if (visitId && ec) { | 218 | if (visit && ec) { |
| 225 | // Update Visit with session end info | 219 | // Update Visit with session end info |
| 226 | ec.service.sync().name("update", "moqui.server.Visit") | 220 | visit.thruDate = ec.user.getNowTimestamp() |
| 227 | .parameters([ | 221 | visit.update() |
| 228 | visitId: visitId, | ||
| 229 | thruDate: ec.user.getNowTimestamp() | ||
| 230 | ]) | ||
| 231 | .call() | ||
| 232 | 222 | ||
| 233 | // Store final session metadata | 223 | // Store final session metadata |
| 234 | sessionMetadata.put("mcpEndedAt", System.currentTimeMillis()) | 224 | def metadata = getSessionMetadata() |
| 235 | sessionMetadata.put("mcpFinalMessageCount", messageCount.get()) | 225 | metadata.mcpEndedAt = System.currentTimeMillis() |
| 226 | metadata.mcpFinalMessageCount = messageCount.get() | ||
| 227 | saveSessionMetadata(metadata) | ||
| 236 | 228 | ||
| 237 | logger.info("Updated Visit ${visitId} with MCP session end info") | 229 | logger.info("Updated Visit ${visit.visitId} with MCP session end info") |
| 238 | } | 230 | } |
| 239 | } catch (Exception e) { | 231 | } catch (Exception e) { |
| 240 | logger.warn("Failed to update session end for Visit ${visitId}: ${e.message}") | 232 | logger.warn("Failed to update session end for Visit ${visit.visitId}: ${e.message}") |
| 241 | } | 233 | } |
| 242 | } | 234 | } |
| 243 | 235 | ||
| 244 | /** | 236 | /** |
| 245 | * Get session metadata | 237 | * Get session metadata from Visit's initialRequest field |
| 246 | */ | 238 | */ |
| 247 | Map getSessionMetadata() { | 239 | Map getSessionMetadata() { |
| 248 | return new HashMap<>(sessionMetadata) | 240 | try { |
| 241 | def metadataJson = visit.initialRequest | ||
| 242 | if (metadataJson) { | ||
| 243 | return groovy.json.JsonSlurper().parseText(metadataJson) as Map | ||
| 244 | } | ||
| 245 | } catch (Exception e) { | ||
| 246 | logger.debug("Failed to parse session metadata: ${e.message}") | ||
| 247 | } | ||
| 248 | return [:] | ||
| 249 | } | 249 | } |
| 250 | 250 | ||
| 251 | /** | 251 | /** |
| 252 | * Add custom metadata to session | 252 | * Add custom metadata to session |
| 253 | */ | 253 | */ |
| 254 | void addSessionMetadata(String key, Object value) { | 254 | void addSessionMetadata(String key, Object value) { |
| 255 | sessionMetadata.put(key, value) | 255 | def metadata = getSessionMetadata() |
| 256 | metadata[key] = value | ||
| 257 | saveSessionMetadata(metadata) | ||
| 258 | } | ||
| 259 | |||
| 260 | /** | ||
| 261 | * Save session metadata to Visit's initialRequest field | ||
| 262 | */ | ||
| 263 | private void saveSessionMetadata(Map metadata) { | ||
| 264 | try { | ||
| 265 | visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) | ||
| 266 | visit.update() | ||
| 267 | } catch (Exception e) { | ||
| 268 | logger.debug("Failed to save session metadata: ${e.message}") | ||
| 269 | } | ||
| 256 | } | 270 | } |
| 257 | } | 271 | } |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
-
Please register or sign in to post a comment