841138a7 by Ean Schuessler

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.
1 parent 0a7ee649
...@@ -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()
388
389 if (!visit || visit.userId != ec.user.userId) {
390 throw new Exception("Invalid session: ${sessionId}")
391 }
392
393 // Update session activity
394 def metadata = [:]
395 try {
396 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
397 } catch (Exception e) {
398 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
399 }
400
401 metadata.mcpLastActivity = System.currentTimeMillis()
402 metadata.mcpLastOperation = "tools/list"
403 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
404 visit.update()
405 }
308 406
407 // Discover all services the user has permission to access
309 def availableTools = [] 408 def availableTools = []
310 ec.logger.info("MCP ToolsList: Checking ${safeServiceNames.size()} services for user ${ec.user.userId}") 409 def allServiceNames = ec.service.getKnownServiceNames()
410 ec.logger.info("MCP ToolsList: Checking ${allServiceNames.size()} services for user ${ec.user.userId}${sessionId ? ' (session: ' + sessionId + ')' : ''}")
311 411
312 // Convert safe services to MCP tools 412 // Helper function to convert service to MCP tool
313 for (serviceName in safeServiceNames) { 413 def convertServiceToTool = { serviceName ->
314 try { 414 try {
315 def serviceDef = ec.service.getServiceDefinition(serviceName)
316 if (!serviceDef) {
317 ec.logger.info("MCP ToolsList: Service ${serviceName} not found")
318 continue
319 }
320
321 // Check permission using Moqui's artifact authorization
322 boolean hasPermission = ec.user.hasPermission(serviceName)
323 ec.logger.info("MCP ToolsList: Service ${serviceName} hasPermission=${hasPermission}")
324 if (!hasPermission) {
325 continue
326 }
327
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
393 availableTools << tool 479 return tool
394
395 } catch (Exception e) { 480 } catch (Exception e) {
396 ec.logger.warn("Error processing service ${serviceName}: ${e.message}") 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) {
497 availableTools << tool
498 }
499 }
500
501 // Now add all other services the user has permission to access
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,
206 thruDate: ec.user.getNowTimestamp()
207 ])
208 .call()
209 201
210 // Could also update a custom field for MCP-specific activity 202 // Update MCP-specific activity in metadata
211 sessionMetadata.put("mcpLastActivity", System.currentTimeMillis()) 203 def metadata = getSessionMetadata()
212 sessionMetadata.put("mcpMessageCount", messageCount.get()) 204 metadata.mcpLastActivity = System.currentTimeMillis()
205 metadata.mcpMessageCount = messageCount.get()
206 saveSessionMetadata(metadata)
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
......