langflow-base-nightly 0.5.0.dev28__py3-none-any.whl → 0.5.0.dev30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
langflow/base/mcp/util.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ import contextlib
3
+ import inspect
2
4
  import os
3
5
  import platform
4
6
  import re
@@ -30,6 +32,13 @@ HTTP_NOT_FOUND = 404
30
32
  HTTP_BAD_REQUEST = 400
31
33
  HTTP_INTERNAL_SERVER_ERROR = 500
32
34
 
35
+ # MCP Session Manager constants
36
+ settings = get_settings_service().settings
37
+ MAX_SESSIONS_PER_SERVER = (
38
+ settings.mcp_max_sessions_per_server
39
+ ) # Maximum number of sessions per server to prevent resource exhaustion
40
+ SESSION_IDLE_TIMEOUT = settings.mcp_session_idle_timeout # 5 minutes idle timeout for sessions
41
+ SESSION_CLEANUP_INTERVAL = settings.mcp_session_cleanup_interval # Cleanup interval in seconds
33
42
  # RFC 7230 compliant header name pattern: token = 1*tchar
34
43
  # tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
35
44
  # "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
@@ -460,12 +469,90 @@ async def _validate_connection_params(mode: str, command: str | None = None, url
460
469
 
461
470
 
462
471
  class MCPSessionManager:
463
- """Manages persistent MCP sessions with proper context manager lifecycle."""
472
+ """Manages persistent MCP sessions with proper context manager lifecycle.
473
+
474
+ Fixed version that addresses the memory leak issue by:
475
+ 1. Session reuse based on server identity rather than unique context IDs
476
+ 2. Maximum session limits per server to prevent resource exhaustion
477
+ 3. Idle timeout for automatic session cleanup
478
+ 4. Periodic cleanup of stale sessions
479
+ """
464
480
 
465
481
  def __init__(self):
466
- self.sessions = {} # context_id -> session_info
482
+ # Structure: server_key -> {"sessions": {session_id: session_info}, "last_cleanup": timestamp}
483
+ self.sessions_by_server = {}
467
484
  self._background_tasks = set() # Keep references to background tasks
468
- self._last_server_by_session = {} # context_id -> server_name for tracking switches
485
+ # Backwards-compatibility maps: which context_id uses which (server_key, session_id)
486
+ self._context_to_session: dict[str, tuple[str, str]] = {}
487
+ # Reference count for each active (server_key, session_id)
488
+ self._session_refcount: dict[tuple[str, str], int] = {}
489
+ self._cleanup_task = None
490
+ self._start_cleanup_task()
491
+
492
+ def _start_cleanup_task(self):
493
+ """Start the periodic cleanup task."""
494
+ if self._cleanup_task is None or self._cleanup_task.done():
495
+ self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
496
+ self._background_tasks.add(self._cleanup_task)
497
+ self._cleanup_task.add_done_callback(self._background_tasks.discard)
498
+
499
+ async def _periodic_cleanup(self):
500
+ """Periodically clean up idle sessions."""
501
+ while True:
502
+ try:
503
+ await asyncio.sleep(SESSION_CLEANUP_INTERVAL)
504
+ await self._cleanup_idle_sessions()
505
+ except asyncio.CancelledError:
506
+ break
507
+ except (RuntimeError, KeyError, ClosedResourceError, ValueError, asyncio.TimeoutError) as e:
508
+ # Handle common recoverable errors without stopping the cleanup loop
509
+ logger.warning(f"Error in periodic cleanup: {e}")
510
+
511
+ async def _cleanup_idle_sessions(self):
512
+ """Clean up sessions that have been idle for too long."""
513
+ current_time = asyncio.get_event_loop().time()
514
+ servers_to_remove = []
515
+
516
+ for server_key, server_data in self.sessions_by_server.items():
517
+ sessions = server_data.get("sessions", {})
518
+ sessions_to_remove = []
519
+
520
+ for session_id, session_info in sessions.items():
521
+ if current_time - session_info["last_used"] > SESSION_IDLE_TIMEOUT:
522
+ sessions_to_remove.append(session_id)
523
+
524
+ # Clean up idle sessions
525
+ for session_id in sessions_to_remove:
526
+ logger.info(f"Cleaning up idle session {session_id} for server {server_key}")
527
+ await self._cleanup_session_by_id(server_key, session_id)
528
+
529
+ # Remove server entry if no sessions left
530
+ if not sessions:
531
+ servers_to_remove.append(server_key)
532
+
533
+ # Clean up empty server entries
534
+ for server_key in servers_to_remove:
535
+ del self.sessions_by_server[server_key]
536
+
537
+ def _get_server_key(self, connection_params, transport_type: str) -> str:
538
+ """Generate a consistent server key based on connection parameters."""
539
+ if transport_type == "stdio":
540
+ if hasattr(connection_params, "command"):
541
+ # Include command, args, and environment for uniqueness
542
+ command_str = f"{connection_params.command} {' '.join(connection_params.args or [])}"
543
+ env_str = str(sorted((connection_params.env or {}).items()))
544
+ key_input = f"{command_str}|{env_str}"
545
+ return f"stdio_{hash(key_input)}"
546
+ elif transport_type == "sse" and (isinstance(connection_params, dict) and "url" in connection_params):
547
+ # Include URL and headers for uniqueness
548
+ url = connection_params["url"]
549
+ headers = str(sorted((connection_params.get("headers", {})).items()))
550
+ key_input = f"{url}|{headers}"
551
+ return f"sse_{hash(key_input)}"
552
+
553
+ # Fallback to a generic key
554
+ # TODO: add option for streamable HTTP in future.
555
+ return f"{transport_type}_{hash(str(connection_params))}"
469
556
 
470
557
  async def _validate_session_connectivity(self, session) -> bool:
471
558
  """Validate that the session is actually usable by testing a simple operation."""
@@ -483,6 +570,7 @@ class MCPSessionManager:
483
570
  "ClosedResourceError" in str(type(e))
484
571
  or "Connection closed" in error_str
485
572
  or "Connection lost" in error_str
573
+ or "Connection failed" in error_str
486
574
  or "Transport closed" in error_str
487
575
  or "Stream closed" in error_str
488
576
  ):
@@ -510,117 +598,83 @@ class MCPSessionManager:
510
598
  return True
511
599
 
512
600
  async def get_session(self, context_id: str, connection_params, transport_type: str):
513
- """Get or create a persistent session."""
514
- # Extract server identifier from connection params for tracking
515
- server_identifier = None
516
- if transport_type == "stdio" and hasattr(connection_params, "command"):
517
- server_identifier = f"stdio_{connection_params.command}"
518
- elif transport_type == "sse" and isinstance(connection_params, dict) and "url" in connection_params:
519
- server_identifier = f"sse_{connection_params['url']}"
520
-
521
- # Check if we're switching servers for this context
522
- server_switched = False
523
- if context_id in self._last_server_by_session:
524
- last_server = self._last_server_by_session[context_id]
525
- if last_server != server_identifier:
526
- server_switched = True
527
- logger.info(f"Detected server switch for context {context_id}: {last_server} -> {server_identifier}")
528
-
529
- # Update server tracking
530
- if server_identifier:
531
- self._last_server_by_session[context_id] = server_identifier
532
-
533
- if context_id in self.sessions:
534
- session_info = self.sessions[context_id]
535
- # Check if session and background task are still alive
536
- try:
537
- session = session_info["session"]
538
- task = session_info["task"]
601
+ """Get or create a session with improved reuse strategy.
539
602
 
540
- # Break down the health check to understand why cleanup is triggered
541
- task_not_done = not task.done()
603
+ The key insight is that we should reuse sessions based on the server
604
+ identity (command + args for stdio, URL for SSE) rather than the context_id.
605
+ This prevents creating a new subprocess for each unique context.
606
+ """
607
+ server_key = self._get_server_key(connection_params, transport_type)
542
608
 
543
- # Additional check for stream health
544
- stream_is_healthy = True
545
- try:
546
- # Check if the session's write stream is still open
547
- if hasattr(session, "_write_stream"):
548
- write_stream = session._write_stream
549
-
550
- # Check for explicit closed state
551
- if hasattr(write_stream, "_closed") and write_stream._closed:
552
- stream_is_healthy = False
553
- # Check anyio stream state for send channels
554
- elif hasattr(write_stream, "_state") and hasattr(write_stream._state, "open_send_channels"):
555
- # Stream is healthy if there are open send channels
556
- stream_is_healthy = write_stream._state.open_send_channels > 0
557
- # Check for other stream closed indicators
558
- elif hasattr(write_stream, "is_closing") and callable(write_stream.is_closing):
559
- stream_is_healthy = not write_stream.is_closing()
560
- # If we can't determine state definitively, try a simple write test
561
- else:
562
- # For streams we can't easily check, assume healthy unless proven otherwise
563
- # The actual tool call will reveal if the stream is truly dead
564
- stream_is_healthy = True
565
-
566
- except (AttributeError, TypeError) as e:
567
- # If we can't check stream health due to missing attributes,
568
- # assume it's healthy and let the tool call fail if it's not
569
- logger.debug(f"Could not check stream health for context_id {context_id}: {e}")
570
- stream_is_healthy = True
571
-
572
- logger.debug(f"Session health check for context_id {context_id}:")
573
- logger.debug(f" - task_not_done: {task_not_done}")
574
- logger.debug(f" - stream_is_healthy: {stream_is_healthy}")
575
-
576
- # For MCP ClientSession, we need both task and stream to be healthy
577
- session_is_healthy = task_not_done and stream_is_healthy
578
-
579
- logger.debug(f" - session_is_healthy: {session_is_healthy}")
580
-
581
- # If we switched servers, always recreate the session to avoid cross-server contamination
582
- if server_switched:
583
- logger.info(f"Server switch detected for context_id {context_id}, forcing session recreation")
584
- session_is_healthy = False
585
-
586
- # Always run connectivity test for sessions to ensure they're truly responsive
587
- # This is especially important when switching between servers
588
- elif session_is_healthy:
589
- logger.debug(f"Running connectivity test for context_id {context_id}")
590
- connectivity_ok = await self._validate_session_connectivity(session)
591
- logger.debug(f" - connectivity_ok: {connectivity_ok}")
592
- if not connectivity_ok:
593
- session_is_healthy = False
594
- logger.info(
595
- f"Session for context_id {context_id} failed connectivity test, marking as unhealthy"
596
- )
597
-
598
- if session_is_healthy:
599
- logger.debug(f"Session for context_id {context_id} is healthy and responsive, reusing")
600
- return session
609
+ # Ensure server entry exists
610
+ if server_key not in self.sessions_by_server:
611
+ self.sessions_by_server[server_key] = {"sessions": {}, "last_cleanup": asyncio.get_event_loop().time()}
601
612
 
602
- if not task_not_done:
603
- msg = f"Session for context_id {context_id} failed health check: background task is done"
604
- logger.info(msg)
605
- elif not stream_is_healthy:
606
- msg = f"Session for context_id {context_id} failed health check: stream is closed"
607
- logger.info(msg)
613
+ server_data = self.sessions_by_server[server_key]
614
+ sessions = server_data["sessions"]
608
615
 
609
- except Exception as e: # noqa: BLE001
610
- msg = f"Session for context_id {context_id} is dead due to exception: {e}"
611
- logger.info(msg)
612
- # Session is dead, clean it up
613
- await self._cleanup_session(context_id)
616
+ # Try to find a healthy existing session
617
+ for session_id, session_info in sessions.items():
618
+ session = session_info["session"]
619
+ task = session_info["task"]
620
+
621
+ # Check if session is still alive
622
+ if not task.done():
623
+ # Update last used time
624
+ session_info["last_used"] = asyncio.get_event_loop().time()
625
+
626
+ # Quick health check
627
+ if await self._validate_session_connectivity(session):
628
+ logger.debug(f"Reusing existing session {session_id} for server {server_key}")
629
+ # record mapping & bump ref-count for backwards compatibility
630
+ self._context_to_session[context_id] = (server_key, session_id)
631
+ self._session_refcount[(server_key, session_id)] = (
632
+ self._session_refcount.get((server_key, session_id), 0) + 1
633
+ )
634
+ return session
635
+ logger.info(f"Session {session_id} for server {server_key} failed health check, cleaning up")
636
+ await self._cleanup_session_by_id(server_key, session_id)
637
+ else:
638
+ # Task is done, clean up
639
+ logger.info(f"Session {session_id} for server {server_key} task is done, cleaning up")
640
+ await self._cleanup_session_by_id(server_key, session_id)
641
+
642
+ # Check if we've reached the maximum number of sessions for this server
643
+ if len(sessions) >= MAX_SESSIONS_PER_SERVER:
644
+ # Remove the oldest session
645
+ oldest_session_id = min(sessions.keys(), key=lambda x: sessions[x]["last_used"])
646
+ logger.info(
647
+ f"Maximum sessions reached for server {server_key}, removing oldest session {oldest_session_id}"
648
+ )
649
+ await self._cleanup_session_by_id(server_key, oldest_session_id)
614
650
 
615
651
  # Create new session
652
+ session_id = f"{server_key}_{len(sessions)}"
653
+ logger.info(f"Creating new session {session_id} for server {server_key}")
654
+
616
655
  if transport_type == "stdio":
617
- return await self._create_stdio_session(context_id, connection_params)
618
- if transport_type == "sse":
619
- return await self._create_sse_session(context_id, connection_params)
620
- msg = f"Unknown transport type: {transport_type}"
621
- raise ValueError(msg)
656
+ session, task = await self._create_stdio_session(session_id, connection_params)
657
+ elif transport_type == "sse":
658
+ session, task = await self._create_sse_session(session_id, connection_params)
659
+ else:
660
+ msg = f"Unknown transport type: {transport_type}"
661
+ raise ValueError(msg)
622
662
 
623
- async def _create_stdio_session(self, context_id: str, connection_params):
663
+ # Store session info
664
+ sessions[session_id] = {
665
+ "session": session,
666
+ "task": task,
667
+ "type": transport_type,
668
+ "last_used": asyncio.get_event_loop().time(),
669
+ }
670
+
671
+ # register mapping & initial ref-count for the new session
672
+ self._context_to_session[context_id] = (server_key, session_id)
673
+ self._session_refcount[(server_key, session_id)] = 1
674
+
675
+ return session
676
+
677
+ async def _create_stdio_session(self, session_id: str, connection_params):
624
678
  """Create a new stdio session as a background task to avoid context issues."""
625
679
  import asyncio
626
680
 
@@ -646,9 +700,7 @@ class MCPSessionManager:
646
700
  try:
647
701
  await event.wait()
648
702
  except asyncio.CancelledError:
649
- # Session is being shut down
650
- msg = "Message is shutting down"
651
- logger.info(msg)
703
+ logger.info(f"Session {session_id} is shutting down")
652
704
  except Exception as e: # noqa: BLE001
653
705
  if not session_future.done():
654
706
  session_future.set_exception(e)
@@ -660,7 +712,7 @@ class MCPSessionManager:
660
712
 
661
713
  # Wait for session to be ready
662
714
  try:
663
- session = await asyncio.wait_for(session_future, timeout=10.0) # 10 second timeout for session creation
715
+ session = await asyncio.wait_for(session_future, timeout=10.0)
664
716
  except asyncio.TimeoutError as timeout_err:
665
717
  # Clean up the failed task
666
718
  if not task.done():
@@ -670,15 +722,13 @@ class MCPSessionManager:
670
722
  with contextlib.suppress(asyncio.CancelledError):
671
723
  await task
672
724
  self._background_tasks.discard(task)
673
- msg = f"Timeout waiting for STDIO session to initialize for context {context_id}"
725
+ msg = f"Timeout waiting for STDIO session {session_id} to initialize"
674
726
  logger.error(msg)
675
727
  raise ValueError(msg) from timeout_err
676
- else:
677
- # Store session info
678
- self.sessions[context_id] = {"session": session, "task": task, "type": "stdio"}
679
- return session
680
728
 
681
- async def _create_sse_session(self, context_id: str, connection_params):
729
+ return session, task
730
+
731
+ async def _create_sse_session(self, session_id: str, connection_params):
682
732
  """Create a new SSE session as a background task to avoid context issues."""
683
733
  import asyncio
684
734
 
@@ -709,9 +759,7 @@ class MCPSessionManager:
709
759
  try:
710
760
  await event.wait()
711
761
  except asyncio.CancelledError:
712
- # Session is being shut down
713
- msg = "Message is shutting down"
714
- logger.info(msg)
762
+ logger.info(f"Session {session_id} is shutting down")
715
763
  except Exception as e: # noqa: BLE001
716
764
  if not session_future.done():
717
765
  session_future.set_exception(e)
@@ -723,7 +771,7 @@ class MCPSessionManager:
723
771
 
724
772
  # Wait for session to be ready
725
773
  try:
726
- session = await asyncio.wait_for(session_future, timeout=10.0) # 10 second timeout for session creation
774
+ session = await asyncio.wait_for(session_future, timeout=10.0)
727
775
  except asyncio.TimeoutError as timeout_err:
728
776
  # Clean up the failed task
729
777
  if not task.done():
@@ -733,21 +781,62 @@ class MCPSessionManager:
733
781
  with contextlib.suppress(asyncio.CancelledError):
734
782
  await task
735
783
  self._background_tasks.discard(task)
736
- msg = f"Timeout waiting for SSE session to initialize for context {context_id}"
784
+ msg = f"Timeout waiting for SSE session {session_id} to initialize"
737
785
  logger.error(msg)
738
786
  raise ValueError(msg) from timeout_err
787
+
788
+ return session, task
789
+
790
+ async def _cleanup_session_by_id(self, server_key: str, session_id: str):
791
+ """Clean up a specific session by server key and session ID."""
792
+ if server_key not in self.sessions_by_server:
793
+ return
794
+
795
+ server_data = self.sessions_by_server[server_key]
796
+ # Handle both old and new session structure
797
+ if isinstance(server_data, dict) and "sessions" in server_data:
798
+ sessions = server_data["sessions"]
739
799
  else:
740
- # Store session info
741
- self.sessions[context_id] = {"session": session, "task": task, "type": "sse"}
742
- return session
800
+ # Handle old structure where sessions were stored directly
801
+ sessions = server_data
743
802
 
744
- async def _cleanup_session(self, context_id: str):
745
- """Clean up a session by cancelling its background task."""
746
- if context_id not in self.sessions:
803
+ if session_id not in sessions:
747
804
  return
748
805
 
749
- session_info = self.sessions[context_id]
806
+ session_info = sessions[session_id]
750
807
  try:
808
+ # First try to properly close the session if it exists
809
+ if "session" in session_info:
810
+ session = session_info["session"]
811
+
812
+ # Try async close first (aclose method)
813
+ if hasattr(session, "aclose"):
814
+ try:
815
+ await session.aclose()
816
+ logger.debug("Successfully closed session %s using aclose()", session_id)
817
+ except Exception as e: # noqa: BLE001
818
+ logger.debug("Error closing session %s with aclose(): %s", session_id, e)
819
+
820
+ # If no aclose, try regular close method
821
+ elif hasattr(session, "close"):
822
+ try:
823
+ # Check if close() is awaitable using inspection
824
+ if inspect.iscoroutinefunction(session.close):
825
+ # It's an async method
826
+ await session.close()
827
+ logger.debug("Successfully closed session %s using async close()", session_id)
828
+ else:
829
+ # Try calling it and check if result is awaitable
830
+ close_result = session.close()
831
+ if inspect.isawaitable(close_result):
832
+ await close_result
833
+ logger.debug("Successfully closed session %s using awaitable close()", session_id)
834
+ else:
835
+ # It's a synchronous close
836
+ logger.debug("Successfully closed session %s using sync close()", session_id)
837
+ except Exception as e: # noqa: BLE001
838
+ logger.debug("Error closing session %s with close(): %s", session_id, e)
839
+
751
840
  # Cancel the background task which will properly close the session
752
841
  if "task" in session_info:
753
842
  task = session_info["task"]
@@ -756,19 +845,76 @@ class MCPSessionManager:
756
845
  try:
757
846
  await task
758
847
  except asyncio.CancelledError:
759
- logger.info(f"Issue cancelling task for context_id {context_id}")
848
+ logger.info(f"Cancelled task for session {session_id}")
760
849
  except Exception as e: # noqa: BLE001
761
- logger.info(f"issue cleaning up mcp session: {e}")
850
+ logger.warning(f"Error cleaning up session {session_id}: {e}")
762
851
  finally:
763
- del self.sessions[context_id]
764
- # Also clean up server tracking
765
- if context_id in self._last_server_by_session:
766
- del self._last_server_by_session[context_id]
852
+ # Remove from sessions dict
853
+ del sessions[session_id]
767
854
 
768
855
  async def cleanup_all(self):
769
856
  """Clean up all sessions."""
770
- for context_id in list(self.sessions.keys()):
771
- await self._cleanup_session(context_id)
857
+ # Cancel periodic cleanup task
858
+ if self._cleanup_task and not self._cleanup_task.done():
859
+ self._cleanup_task.cancel()
860
+ with contextlib.suppress(asyncio.CancelledError):
861
+ await self._cleanup_task
862
+
863
+ # Clean up all sessions
864
+ for server_key in list(self.sessions_by_server.keys()):
865
+ server_data = self.sessions_by_server[server_key]
866
+ # Handle both old and new session structure
867
+ if isinstance(server_data, dict) and "sessions" in server_data:
868
+ sessions = server_data["sessions"]
869
+ else:
870
+ # Handle old structure where sessions were stored directly
871
+ sessions = server_data
872
+
873
+ for session_id in list(sessions.keys()):
874
+ await self._cleanup_session_by_id(server_key, session_id)
875
+
876
+ # Clear the sessions_by_server structure completely
877
+ self.sessions_by_server.clear()
878
+
879
+ # Clear compatibility maps
880
+ self._context_to_session.clear()
881
+ self._session_refcount.clear()
882
+
883
+ # Clear all background tasks
884
+ for task in list(self._background_tasks):
885
+ if not task.done():
886
+ task.cancel()
887
+ with contextlib.suppress(asyncio.CancelledError):
888
+ await task
889
+
890
+ # Give a bit more time for subprocess transports to clean up
891
+ # This helps prevent the BaseSubprocessTransport.__del__ warnings
892
+ await asyncio.sleep(0.5)
893
+
894
+ async def _cleanup_session(self, context_id: str):
895
+ """Backward-compat cleanup by context_id.
896
+
897
+ Decrements the ref-count for the session used by *context_id* and only
898
+ tears the session down when the last context that references it goes
899
+ away.
900
+ """
901
+ mapping = self._context_to_session.get(context_id)
902
+ if not mapping:
903
+ logger.debug(f"No session mapping found for context_id {context_id}")
904
+ return
905
+
906
+ server_key, session_id = mapping
907
+ ref_key = (server_key, session_id)
908
+ remaining = self._session_refcount.get(ref_key, 1) - 1
909
+
910
+ if remaining <= 0:
911
+ await self._cleanup_session_by_id(server_key, session_id)
912
+ self._session_refcount.pop(ref_key, None)
913
+ else:
914
+ self._session_refcount[ref_key] = remaining
915
+
916
+ # Remove the mapping for this context
917
+ self._context_to_session.pop(context_id, None)
772
918
 
773
919
 
774
920
  class MCPStdioClient:
@@ -963,11 +1109,15 @@ class MCPStdioClient:
963
1109
 
964
1110
  async def disconnect(self):
965
1111
  """Properly close the connection and clean up resources."""
966
- # Clean up session using session manager
1112
+ # For stdio transport, there is no remote session to terminate explicitly
1113
+ # The session cleanup happens when the background task is cancelled
1114
+
1115
+ # Clean up local session using the session manager
967
1116
  if self._session_context:
968
1117
  session_manager = self._get_session_manager()
969
1118
  await session_manager._cleanup_session(self._session_context)
970
1119
 
1120
+ # Reset local state
971
1121
  self.session = None
972
1122
  self._connection_params = None
973
1123
  self._connected = False
@@ -1127,19 +1277,34 @@ class MCPSseClient:
1127
1277
 
1128
1278
  # Use cached session manager to get/create persistent session
1129
1279
  session_manager = self._get_session_manager()
1130
- return await session_manager.get_session(self._session_context, self._connection_params, "sse")
1280
+ # Cache session so we can access server-assigned session_id later for DELETE
1281
+ self.session = await session_manager.get_session(self._session_context, self._connection_params, "sse")
1282
+ return self.session
1283
+
1284
+ async def _terminate_remote_session(self) -> None:
1285
+ """Attempt to explicitly terminate the remote MCP session via HTTP DELETE (best-effort)."""
1286
+ # Only relevant for SSE transport
1287
+ if not self._connection_params or "url" not in self._connection_params:
1288
+ return
1131
1289
 
1132
- async def disconnect(self):
1133
- """Properly close the connection and clean up resources."""
1134
- # Clean up session using session manager
1135
- if self._session_context:
1136
- session_manager = self._get_session_manager()
1137
- await session_manager._cleanup_session(self._session_context)
1290
+ url: str = self._connection_params["url"]
1138
1291
 
1139
- self.session = None
1140
- self._connection_params = None
1141
- self._connected = False
1142
- self._session_context = None
1292
+ # Retrieve session id from the underlying SDK if exposed
1293
+ session_id = None
1294
+ if getattr(self, "session", None) is not None:
1295
+ # Common attributes in MCP python SDK: `session_id` or `id`
1296
+ session_id = getattr(self.session, "session_id", None) or getattr(self.session, "id", None)
1297
+
1298
+ headers: dict[str, str] = dict(self._connection_params.get("headers", {}))
1299
+ if session_id:
1300
+ headers["Mcp-Session-Id"] = str(session_id)
1301
+
1302
+ try:
1303
+ async with httpx.AsyncClient(timeout=5.0) as client:
1304
+ await client.delete(url, headers=headers)
1305
+ except Exception as e: # noqa: BLE001
1306
+ # DELETE is advisory—log and continue
1307
+ logger.debug(f"Unable to send session DELETE to '{url}': {e}")
1143
1308
 
1144
1309
  async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
1145
1310
  """Run a tool with the given arguments using context-specific session.
@@ -1253,6 +1418,22 @@ class MCPSseClient:
1253
1418
  logger.error(msg)
1254
1419
  raise ValueError(msg)
1255
1420
 
1421
+ async def disconnect(self):
1422
+ """Properly close the connection and clean up resources."""
1423
+ # Attempt best-effort remote session termination first
1424
+ await self._terminate_remote_session()
1425
+
1426
+ # Clean up local session using the session manager
1427
+ if self._session_context:
1428
+ session_manager = self._get_session_manager()
1429
+ await session_manager._cleanup_session(self._session_context)
1430
+
1431
+ # Reset local state
1432
+ self.session = None
1433
+ self._connection_params = None
1434
+ self._connected = False
1435
+ self._session_context = None
1436
+
1256
1437
  async def __aenter__(self):
1257
1438
  return self
1258
1439
 
@@ -2,6 +2,35 @@ from .model_metadata import create_model_metadata
2
2
 
3
3
  # Unified model metadata - single source of truth
4
4
  OPENAI_MODELS_DETAILED = [
5
+ # GPT-5 Series
6
+ create_model_metadata(
7
+ provider="OpenAI",
8
+ name="gpt-5",
9
+ icon="OpenAI",
10
+ tool_calling=True,
11
+ reasoning=True,
12
+ ),
13
+ create_model_metadata(
14
+ provider="OpenAI",
15
+ name="gpt-5-mini",
16
+ icon="OpenAI",
17
+ tool_calling=True,
18
+ reasoning=True,
19
+ ),
20
+ create_model_metadata(
21
+ provider="OpenAI",
22
+ name="gpt-5-nano",
23
+ icon="OpenAI",
24
+ tool_calling=True,
25
+ reasoning=True,
26
+ ),
27
+ create_model_metadata(
28
+ provider="OpenAI",
29
+ name="gpt-5-chat-latest",
30
+ icon="OpenAI",
31
+ tool_calling=False,
32
+ reasoning=True,
33
+ ),
5
34
  # Regular OpenAI Models
6
35
  create_model_metadata(provider="OpenAI", name="gpt-4o-mini", icon="OpenAI", tool_calling=True),
7
36
  create_model_metadata(provider="OpenAI", name="gpt-4o", icon="OpenAI", tool_calling=True),
@@ -96,6 +96,22 @@ class Settings(BaseSettings):
96
96
  """The number of seconds to wait before giving up on a lock to released or establishing a connection to the
97
97
  database."""
98
98
 
99
+ # ---------------------------------------------------------------------
100
+ # MCP Session-manager tuning
101
+ # ---------------------------------------------------------------------
102
+ mcp_max_sessions_per_server: int = 10
103
+ """Maximum number of MCP sessions to keep per unique server (command/url).
104
+ Mirrors the default constant MAX_SESSIONS_PER_SERVER in util.py. Adjust to
105
+ control resource usage or concurrency per server."""
106
+
107
+ mcp_session_idle_timeout: int = 400 # seconds
108
+ """How long (in seconds) an MCP session can stay idle before the background
109
+ cleanup task disposes of it. Defaults to 5 minutes."""
110
+
111
+ mcp_session_cleanup_interval: int = 120 # seconds
112
+ """Frequency (in seconds) at which the background cleanup task wakes up to
113
+ reap idle sessions."""
114
+
99
115
  # sqlite configuration
100
116
  sqlite_pragmas: dict | None = {"synchronous": "NORMAL", "journal_mode": "WAL"}
101
117
  """SQLite pragmas to use when connecting to the database."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langflow-base-nightly
3
- Version: 0.5.0.dev28
3
+ Version: 0.5.0.dev30
4
4
  Summary: A Python package with a built-in web application
5
5
  Project-URL: Repository, https://github.com/langflow-ai/langflow
6
6
  Project-URL: Documentation, https://docs.langflow.org
@@ -140,7 +140,7 @@ langflow/base/langwatch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG
140
140
  langflow/base/langwatch/utils.py,sha256=wTNQtyItWdl8_cILV6iFkpM0dTHJU5WdXyfccV26scM,448
141
141
  langflow/base/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
142
142
  langflow/base/mcp/constants.py,sha256=-1XnJxejlqm9zs1R91qGtOeX-_F1ZpdHVzCIqUCvhgE,62
143
- langflow/base/mcp/util.py,sha256=ntc_dA6aIG_R8xV7q4JzJwngxfJia_yP1vbtLm6IPMk,56580
143
+ langflow/base/mcp/util.py,sha256=JOml0fB7Iyls1odCPc1xqp42SdKhLrZWYAh4hseqmsc,64044
144
144
  langflow/base/memory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
145
145
  langflow/base/memory/memory.py,sha256=Y0FKZBLvFpQo7CmPO1t2dyJprZcVpWkyJGk0YRd5H2E,1850
146
146
  langflow/base/memory/model.py,sha256=iCO45tdH_Pz9txI7044KB1c9y5M6nSLjIeIu0_YeSpA,1350
@@ -157,7 +157,7 @@ langflow/base/models/model_metadata.py,sha256=tNFPiRqBJ0WPKdNEqBxuoKk0n8H_h0J--b
157
157
  langflow/base/models/model_utils.py,sha256=RwXUSIw5gdRakQ-VGbLI1iT0CeeWrVSNTgUQIrrc6uE,474
158
158
  langflow/base/models/novita_constants.py,sha256=_mgBYGwpddUw4CLhLKJl-psOUzA_SQGHrfZJUNes6aI,1247
159
159
  langflow/base/models/ollama_constants.py,sha256=FM0BPEtuEmIoH2K1t6tTh5h_H2bK7-YSXe0x4-F4Mik,973
160
- langflow/base/models/openai_constants.py,sha256=Cd7VzE5pDp2_ZAyUzs5g-DJzC5ykg82mOHb_6fZ-kio,4083
160
+ langflow/base/models/openai_constants.py,sha256=xtsyWzClkJwlIBZm-3Jt3x-XWfu2-nPp67faNCWQwdk,4753
161
161
  langflow/base/models/sambanova_constants.py,sha256=mYPF7vUbMow9l4jQ2OJrIkAJhGs3fGWTCVNfG3oQZTc,519
162
162
  langflow/base/processing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
163
163
  langflow/base/prompts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -1011,7 +1011,7 @@ langflow/services/session/service.py,sha256=KR82A1sycF2gmb7zeqxn4EZ0_NxZcQN1ggRs
1011
1011
  langflow/services/session/utils.py,sha256=YkInqhL019XrgIyovOtkLqFF3jJmlQBSPbKiKOuWGiM,516
1012
1012
  langflow/services/settings/__init__.py,sha256=UISBvOQIqoA3a8opwJrTQp4PSTqpReY6GQ_7O6WuqJQ,65
1013
1013
  langflow/services/settings/auth.py,sha256=jpVRROmFtNp1rqoHYFINzsdnQ_QrglyLgB_uVHhpNVI,4562
1014
- langflow/services/settings/base.py,sha256=rKu4YYcpx9LVOymMJeWaSHT_y15DDc_HxWw2RGbg1hs,23450
1014
+ langflow/services/settings/base.py,sha256=ALzdNsYhEAyMd14WDWbvbnUZ6xVsUofpO1oq7ZhVIgs,24246
1015
1015
  langflow/services/settings/constants.py,sha256=Uf8HrGOpRE-55IZ7aopsZsEnzBb9rRPi--PFvJfUGqw,878
1016
1016
  langflow/services/settings/factory.py,sha256=Jf0leRvzUBlxZ6BsoCJEDKdH2kWR9Tiv-Dk8Y7cbqUE,595
1017
1017
  langflow/services/settings/feature_flags.py,sha256=6uR8hwQwdUsveBW7-im2qgxwd8pT-hqf8rz53_zDtQI,236
@@ -1105,7 +1105,7 @@ langflow/utils/util_strings.py,sha256=Blz5lwvE7lml7nKCG9vVJ6me5VNmVtYzFXDVPHPK7v
1105
1105
  langflow/utils/validate.py,sha256=8RnY61LZFCBU1HIlPDCMI3vsXOmK_IFAYBGZIfZJcsU,16362
1106
1106
  langflow/utils/version.py,sha256=OjSj0smls9XnPd4-LpTH9AWyUO_NAn5mncqKkkXl_fw,2840
1107
1107
  langflow/utils/voice_utils.py,sha256=pzU6uuseI2_5mi-yXzFIjMavVRFyuVrpLmR6LqbF7mE,3346
1108
- langflow_base_nightly-0.5.0.dev28.dist-info/METADATA,sha256=ZULLdWeEBwdZpY7ESP5KUVu-GdiZhRTW92Pp4q01NnU,4173
1109
- langflow_base_nightly-0.5.0.dev28.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
1110
- langflow_base_nightly-0.5.0.dev28.dist-info/entry_points.txt,sha256=JvuLdXSrkeDmDdpb8M-VvFIzb84n4HmqUcIP10_EIF8,57
1111
- langflow_base_nightly-0.5.0.dev28.dist-info/RECORD,,
1108
+ langflow_base_nightly-0.5.0.dev30.dist-info/METADATA,sha256=kFgHD6t-C99NX4tCoSrxW1wTbfbFCFr3mcVdEipr_IQ,4173
1109
+ langflow_base_nightly-0.5.0.dev30.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
1110
+ langflow_base_nightly-0.5.0.dev30.dist-info/entry_points.txt,sha256=JvuLdXSrkeDmDdpb8M-VvFIzb84n4HmqUcIP10_EIF8,57
1111
+ langflow_base_nightly-0.5.0.dev30.dist-info/RECORD,,