-
Notifications
You must be signed in to change notification settings - Fork 30
feat: Reuse MCP session between tool calls #502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements session reuse for MCP (Model Context Protocol) tools to improve performance by avoiding repeated initialization overhead when tools from the same resource configuration are invoked multiple times.
Changes:
- Introduced a custom
streamable_http_clientimplementation that accepts an optionalsession_idparameter for reusing existing server-side sessions - Added closure variable
session_idshared across all tools created from the sameAgentMcpResourceConfigto track and reuse sessions - Modified session initialization logic to skip initialization when connecting with an existing session ID
- Added comprehensive test suite with 14 tests covering metadata, tool creation, functionality, session reuse, and name sanitization
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| src/uipath_langchain/agent/tools/mcp_tool.py | Implements session reuse mechanism through custom streamable HTTP client and closure-scoped session_id variable |
| tests/agent/tools/test_mcp_tool.py | Adds comprehensive test coverage for MCP tool functionality including session reuse validation |
Comments suppressed due to low confidence (1)
src/uipath_langchain/agent/tools/mcp_tool.py:275
- The session ID is stored in a closure variable but is never cleared or reset. If a session fails, expires, or becomes invalid, the tool will continue attempting to reuse the stale session_id, causing subsequent tool invocations to fail without recovery.
Consider adding error handling that clears the session_id when session operations fail, allowing the next invocation to create a fresh session. For example, wrap the session operations in a try-except block and set session_id = None on failure to enable recovery.
if not session_id:
await session.initialize()
session_id = getSessionId()
logger.info(f"session {session_id} created")
# Call the tool
result = await session.call_tool(mcp_tool.name, arguments=kwargs)
return result.content if hasattr(result, "content") else result
| # Create streamable connection | ||
| read, write, _ = await stack.enter_async_context( | ||
| nonlocal session_id | ||
| logger.info(f"Connecting to session {session_id}") |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logging statement includes user-controlled data (session_id) which could potentially contain sensitive information or be used for log injection. While session IDs are typically GUIDs, it's better to validate the format or use structured logging to prevent any potential log injection issues.
Consider using structured logging with separate fields, or at minimum validating that the session_id matches expected GUID format before logging.
| if not session_id: | ||
| await session.initialize() | ||
| session_id = getSessionId() | ||
| logger.info(f"session {session_id} created") |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logging statement includes the session_id which could potentially contain sensitive information or be used for log injection. While session IDs are typically GUIDs, it's better to validate the format or use structured logging to prevent any potential log injection issues.
Consider using structured logging with separate fields, or at minimum validating that the session_id matches expected GUID format before logging.
| url: The MCP server endpoint URL. | ||
| http_client: Optional pre-configured httpx.AsyncClient. If None, a default | ||
| client with recommended MCP timeouts will be created. To configure headers, | ||
| authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docstring parameter session_id is missing from the Args section. The function accepts a session_id parameter but it's not documented in the docstring.
Add documentation for the session_id parameter to explain that it's an optional session ID for reusing an existing MCP session.
| authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. | |
| authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. | |
| session_id: Optional session ID for reusing an existing MCP session instead of creating a new one. |
| # Lazy import to improve cold start time | ||
| import contextlib | ||
| import logging | ||
| from collections.abc import AsyncGenerator |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate import: AsyncGenerator is imported from collections.abc here but is already imported from typing at the top of the file (line 6). This creates ambiguity and is unnecessary.
Remove this import line as the typing.AsyncGenerator import at the top of the file is sufficient for the entire module.
| from collections.abc import AsyncGenerator |
| import contextlib | ||
| import logging | ||
| from collections.abc import AsyncGenerator | ||
| from contextlib import asynccontextmanager |
Copilot
AI
Jan 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate import: asynccontextmanager is imported from contextlib here but is already imported at the top of the file (line 4). This creates ambiguity and is unnecessary.
Remove this import line as the contextlib.asynccontextmanager import at the top of the file is sufficient for the entire module.
| from contextlib import asynccontextmanager |
aef44bf to
66056b1
Compare
66056b1 to
41a5daa
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| if TYPE_CHECKING: | ||
| pass | ||
|
|
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty TYPE_CHECKING block with no imports. This block should either contain type-only imports or be removed entirely.
| if TYPE_CHECKING: | |
| pass |
| async with session_lock: | ||
| logger.debug(f"Connecting to session {session_id}") | ||
| read, write, getSessionId = await stack.enter_async_context( | ||
| streamable_http_client( | ||
| url=f"{mcpServer.mcp_url}", | ||
| http_client=http_client, | ||
| session_id=session_id, | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
| # Create and initialize session | ||
| session = await stack.enter_async_context( | ||
| ClientSession(read, write) | ||
| ) | ||
| await session.initialize() | ||
| # Create and initialize session | ||
| session = await stack.enter_async_context( | ||
| ClientSession(read, write) | ||
| ) |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The session_lock is created once at module level (per resource config) but each tool function creates its own AsyncExitStack and resources. If two tool invocations from different tools execute concurrently:
- First tool acquires lock, creates session, releases lock
- Second tool acquires lock, creates its own separate ClientSession with the same session_id
- Both tools are now using different ClientSession objects but with the same session_id
This could lead to undefined behavior if the MCP protocol doesn't support multiple concurrent ClientSessions with the same session_id. Consider either:
- Sharing a single ClientSession across all tool invocations (requiring it to live outside the tool_fn scope)
- Using a session pool with proper lifecycle management
- Documenting that the MCP protocol supports this usage pattern
| async with session_lock: | ||
| logger.debug(f"Connecting to session {session_id}") | ||
| read, write, getSessionId = await stack.enter_async_context( | ||
| streamable_http_client( | ||
| url=f"{mcpServer.mcp_url}", | ||
| http_client=http_client, | ||
| session_id=session_id, | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
| # Create and initialize session | ||
| session = await stack.enter_async_context( | ||
| ClientSession(read, write) | ||
| ) | ||
| await session.initialize() | ||
| # Create and initialize session | ||
| session = await stack.enter_async_context( | ||
| ClientSession(read, write) | ||
| ) | ||
|
|
||
| if not session_id: | ||
| await session.initialize() | ||
| session_id = getSessionId() | ||
| logger.info(f"session {session_id} created") |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lock is held during the entire session initialization process including entering the async context and creating the ClientSession. This prevents concurrent tool invocations from proceeding, even though they could potentially share the same session after initialization completes.
Consider narrowing the lock scope to only protect the session_id check and assignment. The session initialization and ClientSession creation could happen outside the lock, with only the session_id variable access being synchronized. This would allow better concurrency while still ensuring thread-safe session ID management.
| Each tool manages its own session lifecycle - creating, using, and cleaning up | ||
| the MCP connection within the tool invocation. |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function-level docstring states "Each tool manages its own session lifecycle - creating, using, and cleaning up the MCP connection within the tool invocation." However, the implementation has changed to reuse sessions across multiple tool invocations via the closure variable session_id. This docstring should be updated to reflect that sessions are now shared and reused across all tool invocations from the same resource config.
| Each tool manages its own session lifecycle - creating, using, and cleaning up | |
| the MCP connection within the tool invocation. | |
| MCP sessions are shared and reused across all tool invocations derived from the | |
| same resource configuration. A shared MCP client/transport (and associated | |
| session identifier) manages the underlying connection lifecycle rather than | |
| each tool invocation creating and tearing down its own session. |
| await session.initialize() | ||
| session_id = getSessionId() | ||
| logger.info(f"session {session_id} created") |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling for session initialization failure. If session.initialize() throws an exception, the session_id remains None but the lock is released, allowing subsequent tool invocations to attempt initialization again without any coordination. This could lead to multiple concurrent initialization attempts.
Consider adding error handling that either:
- Sets a flag to indicate initialization failed so subsequent calls don't retry immediately
- Re-raises the error after cleanup
- Implements retry logic with exponential backoff
| await session.initialize() | |
| session_id = getSessionId() | |
| logger.info(f"session {session_id} created") | |
| try: | |
| await session.initialize() | |
| session_id = getSessionId() | |
| logger.info(f"session {session_id} created") | |
| except Exception: | |
| # Ensure we do not keep a partially initialized session_id | |
| session_id = None | |
| logger.exception("Failed to initialize MCP session") | |
| raise |
| ](0) | ||
|
|
||
| transport = StreamableHTTPTransport(url) | ||
| transport.session_id = session_id # type: ignore[assignment] |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type: ignore[assignment] comment indicates that session_id is being assigned to a private or incompatible attribute on StreamableHTTPTransport. This suggests the implementation is relying on internal implementation details of the transport that may not be part of its public API. If StreamableHTTPTransport doesn't officially support setting session_id, this could break in future versions of the mcp library.
Consider either:
- Checking if there's an official API for session reuse in the mcp library
- Adding a comment explaining why this is necessary and what the risk is
- Implementing a more robust approach that doesn't rely on internal attributes
| transport.session_id = session_id # type: ignore[assignment] | |
| # Note: StreamableHTTPTransport.session_id is not part of a documented public API | |
| # in type hints, and may change in future versions of the mcp library. We only | |
| # set it if present to enable session reuse while avoiding hard runtime failures | |
| # if the attribute is removed or renamed. | |
| if session_id is not None and hasattr(transport, "session_id"): | |
| setattr(transport, "session_id", session_id) |
Summary
MCP tools created from metadata now reuse the same session across multiple tool invocations, significantly improving performance by avoiding repeated initialization overhead.
Changes
Session Reuse Implementation
Previously, each tool invocation created a new MCP session, requiring:
Now, all tools created from the same
AgentMcpResourceConfigshare a single session using a closure variable:Custom StreamableHTTP Client
Added a custom
streamable_http_client()implementation that:session_idparameter for session reuseget_session_id()callback for retrieving the current sessionBenefits
Future Work
Session Termination: The current implementation does not terminate sessions when tools are done. Session cleanup and proper termination will be added in a future PR to ensure resources are released appropriately.
Tests
Added comprehensive test suite with 14 tests covering all aspects of MCP tool functionality:
TestMcpToolMetadata (5 tests)
Validates that tools have correct metadata for observability and tracing:
test_mcp_tool_has_metadata- Verifies metadata dict existstest_mcp_tool_metadata_has_tool_type- Validatestool_type: "mcp"for span detectiontest_mcp_tool_metadata_has_display_name- Checks display name from tool nametest_mcp_tool_metadata_has_folder_path- Verifies folder path for span attributestest_mcp_tool_metadata_has_slug- Validates server slug for identificationTestMcpToolCreation (3 tests)
Tests tool creation and configuration:
test_creates_multiple_tools- Verifies multiple tools created from configtest_tool_has_correct_description- Validates tool descriptionstest_disabled_config_returns_empty_list- Tests disabled config handlingTestMcpToolFunctionality (2 tests)
Tests tool invocation with mocked HTTP layer:
test_tool_invocation_calls_mcp_server- Validates complete tool invocation flow with GUID session IDtest_tool_returns_result_without_content_attribute- Tests handling of results without content attributeTestMcpToolSessionReuse (2 tests)
Key tests validating the session reuse feature:
test_tools_share_same_session_id_variable- Verifies session reuse at HTTP level:initializecall for 2 tool invocationsmcp-session-idheaderClientSessioncode with mocked HTTP client for realistic testingtest_session_not_terminated_between_calls- Validates session not terminated between tool invocationsTestMcpToolNameSanitization (2 tests)
Tests tool name sanitization:
test_tool_name_with_spaces- Verifies spaces removed from tool namestest_tool_name_with_special_chars- Tests special character handlingTesting Approach
Tests use strategic mocking:
httpx.AsyncClient) to allow real MCP protocol code executioninitialize,tools/call,tools/list, and notification methodsTechnical Details
Closure Scoping with
nonlocalThe session reuse mechanism relies on Python closure scoping:
session_idvariable declared at the outer scope (per resource config)nonlocal session_idto access and modify the shared variableMCP Protocol Flow
First Tool Invocation:
session_idisNonesession.initialize()getSessionId()Subsequent Tool Invocations:
session_idis set from previous callHTTP Headers
The session ID is transmitted via the
mcp-session-idHTTP header:Migration
No breaking changes. Existing code continues to work, but benefits from improved performance through automatic session reuse.