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 +329 -148
- langflow/base/models/openai_constants.py +29 -0
- langflow/services/settings/base.py +16 -0
- {langflow_base_nightly-0.5.0.dev28.dist-info → langflow_base_nightly-0.5.0.dev30.dist-info}/METADATA +1 -1
- {langflow_base_nightly-0.5.0.dev28.dist-info → langflow_base_nightly-0.5.0.dev30.dist-info}/RECORD +7 -7
- {langflow_base_nightly-0.5.0.dev28.dist-info → langflow_base_nightly-0.5.0.dev30.dist-info}/WHEEL +0 -0
- {langflow_base_nightly-0.5.0.dev28.dist-info → langflow_base_nightly-0.5.0.dev30.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
#
|
|
741
|
-
|
|
742
|
-
return session
|
|
800
|
+
# Handle old structure where sessions were stored directly
|
|
801
|
+
sessions = server_data
|
|
743
802
|
|
|
744
|
-
|
|
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 =
|
|
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"
|
|
848
|
+
logger.info(f"Cancelled task for session {session_id}")
|
|
760
849
|
except Exception as e: # noqa: BLE001
|
|
761
|
-
logger.
|
|
850
|
+
logger.warning(f"Error cleaning up session {session_id}: {e}")
|
|
762
851
|
finally:
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
771
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
self
|
|
1142
|
-
|
|
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."""
|
{langflow_base_nightly-0.5.0.dev28.dist-info → langflow_base_nightly-0.5.0.dev30.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langflow-base-nightly
|
|
3
|
-
Version: 0.5.0.
|
|
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
|
{langflow_base_nightly-0.5.0.dev28.dist-info → langflow_base_nightly-0.5.0.dev30.dist-info}/RECORD
RENAMED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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.
|
|
1109
|
-
langflow_base_nightly-0.5.0.
|
|
1110
|
-
langflow_base_nightly-0.5.0.
|
|
1111
|
-
langflow_base_nightly-0.5.0.
|
|
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,,
|
{langflow_base_nightly-0.5.0.dev28.dist-info → langflow_base_nightly-0.5.0.dev30.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|