From eb7359c64ba3ad22ee1d8841614ae91a6afbc94d Mon Sep 17 00:00:00 2001 From: Mendon Kissling <59585235+mendonk@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:05:14 -0400 Subject: [PATCH 1/6] docs: health_check API reference (#10134) * docs-api-reference-health-check-add-pragma * remove-pragma-flag --- .../api-reference-api-examples.mdx | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/docs/API-Reference/api-reference-api-examples.mdx b/docs/docs/API-Reference/api-reference-api-examples.mdx index 60f954e97e..83b79c5832 100644 --- a/docs/docs/API-Reference/api-reference-api-examples.mdx +++ b/docs/docs/API-Reference/api-reference-api-examples.mdx @@ -122,6 +122,32 @@ You can retrieve flow IDs from the [**API access** pane](/concepts-publish#api-a Once you have your Langflow server URL, try calling these endpoints that return Langflow metadata. +### Health check + +Returns the health status of the Langflow database and chat services: + +```bash +curl -X GET \ + "$LANGFLOW_SERVER_URL/health_check" \ + -H "accept: application/json" +``` + +
+Result + +```json +{ + "status": "ok", + "chat": "ok", + "db": "ok" +} +``` + +
+ +Langflow provides an additional `GET /health` endpoint. +This endpoint is served by uvicorn before Langflow is fully initialized, so it's not reliable for checking Langflow service health. + ### Get version Returns the current Langflow API version: @@ -130,7 +156,6 @@ Returns the current Langflow API version: curl -X GET \ "$LANGFLOW_SERVER_URL/api/v1/version" \ -H "accept: application/json" - -H "x-api-key: $LANGFLOW_API_KEY" ```
@@ -138,8 +163,8 @@ curl -X GET \ ```text { - "version": "1.1.1", - "main_version": "1.1.1", + "version": "1.6.0", + "main_version": "1.6.0", "package": "Langflow" } ``` @@ -154,7 +179,6 @@ Returns configuration details for your Langflow deployment: curl -X GET \ "$LANGFLOW_SERVER_URL/api/v1/config" \ -H "accept: application/json" - -H "x-api-key: $LANGFLOW_API_KEY" ```
@@ -165,11 +189,21 @@ curl -X GET \ "feature_flags": { "mvp_components": false }, + "serialization_max_items_length": 1000, + "serialization_max_text_length": 6000, "frontend_timeout": 0, "auto_saving": true, "auto_saving_interval": 1000, "health_check_max_retries": 5, - "max_file_size_upload": 1024 + "max_file_size_upload": 1024, + "webhook_polling_interval": 5000, + "public_flow_cleanup_interval": 3600, + "public_flow_expiration": 86400, + "event_delivery": "streaming", + "webhook_auth_enable": false, + "voice_mode_available": false, + "default_folder_name": "Starter Project", + "hide_getting_started_progress": false } ``` @@ -177,7 +211,8 @@ curl -X GET \ ### Get all components -Returns a dictionary of all Langflow components: +Returns a dictionary of all Langflow components. +Requires a [Langflow API key](/api-keys-and-authentication). ```bash curl -X GET \ @@ -215,6 +250,7 @@ Other endpoints are helpful for specific use cases, such as administration and f * Deployment details: * GET `/v1/version`: Return Langflow version. See [Get version](/api-reference-api-examples#get-version). * GET `/v1/config`: Return deployment configuration. See [Get configuration](/api-reference-api-examples#get-configuration). + * GET `/health_check`: Health check endpoint that validates database and chat service connectivity. Returns 500 status if any service is unavailable. * [Projects endpoints](/api-projects): * POST `/v1/projects/`: Create a project. From 7c2e1fa2ccfe6033f56d9eb23c7690ce65552ff5 Mon Sep 17 00:00:00 2001 From: Gabriel Luiz Freitas Almeida Date: Tue, 7 Oct 2025 13:00:11 -0300 Subject: [PATCH 2/6] fix: defer message updates in db when streaming until message is complete (#10103) * refactor: Optimize message update process in aupdate_messages function - Batch commit all updates at once to improve performance. - Skip refresh during commit as message objects already contain updated values, reducing unnecessary database round-trips. - Added comments for clarity on the changes made to enhance code documentation. * feat: Enhance send_message method with skip_db_update option - Added a new parameter `skip_db_update` to the `send_message` method, allowing control over database updates during message sending. - Updated method documentation to clarify the purpose and usage of the new parameter. - Adjusted logic to handle in-memory updates without database writes when `skip_db_update` is set to True, improving performance during streaming operations. * refactor: Update send_message calls to utilize skip_db_update option - Modified multiple instances of the send_message_method to include the new `skip_db_update` parameter, enhancing performance by avoiding unnecessary database updates during streaming operations. - Added comments to clarify the purpose of using `skip_db_update=True` in the context of streaming events. - Ensured that the final message update after processing events defaults to a database update, maintaining data integrity. * refactor: Improve message handling in send_message method - Enhanced the logic for handling the `skip_db_update` parameter in the `send_message` method to ensure a fresh Message instance is created for consistency during streaming operations. - Updated comments to clarify the behavior when `skip_db_update` is set to True, emphasizing that the message must already exist in the database. - Maintained the real-time event update functionality while avoiding unnecessary database writes, improving performance during streaming. * refactor: Update send_message mocks to include skip_db_update parameter - Modified multiple test cases in `test_agent_events.py` to incorporate the `skip_db_update` parameter in the `send_message` mock, ensuring consistency with recent changes in message handling. - Enhanced clarity in the tests by explicitly defining the behavior of the `send_message` method when `skip_db_update` is set to False, maintaining the integrity of event handling during tests. --- src/backend/base/langflow/memory.py | 10 +++- .../components/agents/test_agent_events.py | 38 ++++++------ src/lfx/src/lfx/base/agents/events.py | 18 +++--- .../lfx/custom/custom_component/component.py | 60 ++++++++++++------- src/lfx/src/lfx/schema/log.py | 1 + 5 files changed, 79 insertions(+), 48 deletions(-) diff --git a/src/backend/base/langflow/memory.py b/src/backend/base/langflow/memory.py index a8f41e5516..311c3fad57 100644 --- a/src/backend/base/langflow/memory.py +++ b/src/backend/base/langflow/memory.py @@ -147,13 +147,19 @@ async def aupdate_messages(messages: Message | list[Message]) -> list[Message]: if msg.flow_id and isinstance(msg.flow_id, str): msg.flow_id = UUID(msg.flow_id) session.add(msg) - await session.commit() - await session.refresh(msg) updated_messages.append(msg) else: error_message = f"Message with id {message.id} not found" await logger.awarning(error_message) raise ValueError(error_message) + + # Batch commit all updates at once + await session.commit() + + # Skip refresh during commit - the msg objects already have the updated values + # Refresh is only needed if we need database-generated values (like timestamps) + # For streaming performance, we skip this extra round-trip + return [MessageRead.model_validate(message, from_attributes=True) for message in updated_messages] diff --git a/src/backend/tests/unit/components/agents/test_agent_events.py b/src/backend/tests/unit/components/agents/test_agent_events.py index a2e55deb3e..9dd9915078 100644 --- a/src/backend/tests/unit/components/agents/test_agent_events.py +++ b/src/backend/tests/unit/components/agents/test_agent_events.py @@ -27,7 +27,7 @@ async def create_event_iterator(events: list[dict[str, Any]]) -> AsyncIterator[d async def test_chain_start_event(): """Test handling of on_chain_start event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 events = [ {"event": "on_chain_start", "data": {"input": {"input": "test input", "chat_history": []}}, "start_time": 0} @@ -52,7 +52,7 @@ async def test_chain_start_event(): async def test_chain_end_event(): """Test handling of on_chain_end event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 # Create a mock AgentFinish output output = AgentFinish(return_values={"output": "final output"}, log="test log") @@ -81,7 +81,7 @@ async def test_tool_start_event(): send_message = AsyncMock() # Set up the send_message mock to return the modified message - def update_message(message): + def update_message(message, skip_db_update=False): # noqa: ARG001, FBT002 # Return a copy of the message to simulate real behavior return Message(**message.model_dump()) @@ -117,7 +117,7 @@ async def test_tool_start_event(): async def test_tool_end_event(): """Test handling of on_tool_end event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 events = [ { @@ -152,7 +152,7 @@ async def test_tool_end_event(): async def test_tool_error_event(): """Test handling of on_tool_error event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 events = [ { @@ -188,7 +188,7 @@ async def test_tool_error_event(): async def test_chain_stream_event(): """Test handling of on_chain_stream event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 events = [{"event": "on_chain_stream", "data": {"chunk": {"output": "streamed output"}}, "start_time": 0}] agent_message = Message( @@ -206,7 +206,7 @@ async def test_chain_stream_event(): async def test_multiple_events(): """Test handling of multiple events in sequence.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 # Create a mock AgentFinish output instead of MockOutput output = AgentFinish(return_values={"output": "final output"}, log="test log") @@ -249,7 +249,7 @@ async def test_multiple_events(): async def test_unknown_event(): """Test handling of unknown event type.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -274,7 +274,7 @@ async def test_unknown_event(): async def test_handle_on_chain_start_with_input(): """Test handle_on_chain_start with input.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -293,7 +293,7 @@ async def test_handle_on_chain_start_with_input(): async def test_handle_on_chain_start_no_input(): """Test handle_on_chain_start without input.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -312,7 +312,7 @@ async def test_handle_on_chain_start_no_input(): async def test_handle_on_chain_end_with_output(): """Test handle_on_chain_end with output.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -333,7 +333,7 @@ async def test_handle_on_chain_end_with_output(): async def test_handle_on_chain_end_no_output(): """Test handle_on_chain_end without output key in data.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -352,7 +352,7 @@ async def test_handle_on_chain_end_no_output(): async def test_handle_on_chain_end_empty_data(): """Test handle_on_chain_end with empty data.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -371,7 +371,7 @@ async def test_handle_on_chain_end_empty_data(): async def test_handle_on_chain_end_with_empty_return_values(): """Test handle_on_chain_end with empty return_values.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -395,7 +395,7 @@ async def test_handle_on_chain_end_with_empty_return_values(): async def test_handle_on_tool_start(): """Test handle_on_tool_start event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 tool_blocks_map = {} agent_message = Message( sender=MESSAGE_SENDER_AI, @@ -427,7 +427,7 @@ async def test_handle_on_tool_start(): async def test_handle_on_tool_end(): """Test handle_on_tool_end event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 tool_blocks_map = {} agent_message = Message( sender=MESSAGE_SENDER_AI, @@ -464,7 +464,7 @@ async def test_handle_on_tool_end(): async def test_handle_on_tool_error(): """Test handle_on_tool_error event.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 tool_blocks_map = {} agent_message = Message( sender=MESSAGE_SENDER_AI, @@ -503,7 +503,7 @@ async def test_handle_on_tool_error(): async def test_handle_on_chain_stream_with_output(): """Test handle_on_chain_stream with output.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", @@ -524,7 +524,7 @@ async def test_handle_on_chain_stream_with_output(): async def test_handle_on_chain_stream_no_output(): """Test handle_on_chain_stream without output.""" - send_message = AsyncMock(side_effect=lambda message: message) + send_message = AsyncMock(side_effect=lambda message, skip_db_update=False: message) # noqa: ARG005 agent_message = Message( sender=MESSAGE_SENDER_AI, sender_name="Agent", diff --git a/src/lfx/src/lfx/base/agents/events.py b/src/lfx/src/lfx/base/agents/events.py index e8a39c3e80..78df6340a8 100644 --- a/src/lfx/src/lfx/base/agents/events.py +++ b/src/lfx/src/lfx/base/agents/events.py @@ -80,7 +80,7 @@ async def handle_on_chain_start( header={"title": "Input", "icon": "MessageSquare"}, ) agent_message.content_blocks[0].contents.append(text_content) - agent_message = await send_message_method(message=agent_message) + agent_message = await send_message_method(message=agent_message, skip_db_update=True) start_time = perf_counter() return agent_message, start_time @@ -151,7 +151,7 @@ async def handle_on_chain_end( header={"title": "Output", "icon": "MessageSquare"}, ) agent_message.content_blocks[0].contents.append(text_content) - agent_message = await send_message_method(message=agent_message) + agent_message = await send_message_method(message=agent_message, skip_db_update=True) start_time = perf_counter() return agent_message, start_time @@ -190,7 +190,7 @@ async def handle_on_tool_start( tool_blocks_map[tool_key] = tool_content agent_message.content_blocks[0].contents.append(tool_content) - agent_message = await send_message_method(message=agent_message) + agent_message = await send_message_method(message=agent_message, skip_db_update=True) if agent_message.content_blocks and agent_message.content_blocks[0].contents: tool_blocks_map[tool_key] = agent_message.content_blocks[0].contents[-1] return agent_message, new_start_time @@ -210,7 +210,7 @@ async def handle_on_tool_end( if tool_content and isinstance(tool_content, ToolContent): # Call send_message_method first to get the updated message structure - agent_message = await send_message_method(message=agent_message) + agent_message = await send_message_method(message=agent_message, skip_db_update=True) new_start_time = perf_counter() # Now find and update the tool content in the current message @@ -258,7 +258,7 @@ async def handle_on_tool_error( tool_content.error = event["data"].get("error", "Unknown error") tool_content.duration = _calculate_duration(start_time) tool_content.header = {"title": f"Error using **{tool_content.name}**", "icon": "Hammer"} - agent_message = await send_message_method(message=agent_message) + agent_message = await send_message_method(message=agent_message, skip_db_update=True) start_time = perf_counter() return agent_message, start_time @@ -275,14 +275,14 @@ async def handle_on_chain_stream( if output and isinstance(output, str | list): agent_message.text = _extract_output_text(output) agent_message.properties.state = "complete" - agent_message = await send_message_method(message=agent_message) + agent_message = await send_message_method(message=agent_message, skip_db_update=True) start_time = perf_counter() elif isinstance(data_chunk, AIMessageChunk): output_text = _extract_output_text(data_chunk.content) if output_text and isinstance(agent_message.text, str): agent_message.text += output_text agent_message.properties.state = "partial" - agent_message = await send_message_method(message=agent_message) + agent_message = await send_message_method(message=agent_message, skip_db_update=True) if not agent_message.text: start_time = perf_counter() return agent_message, start_time @@ -346,13 +346,17 @@ async def process_agent_events( async for event in agent_executor: if event["event"] in TOOL_EVENT_HANDLERS: tool_handler = TOOL_EVENT_HANDLERS[event["event"]] + # Use skip_db_update=True during streaming to avoid DB round-trips agent_message, start_time = await tool_handler( event, agent_message, tool_blocks_map, send_message_method, start_time ) elif event["event"] in CHAIN_EVENT_HANDLERS: chain_handler = CHAIN_EVENT_HANDLERS[event["event"]] + # Use skip_db_update=True during streaming to avoid DB round-trips agent_message, start_time = await chain_handler(event, agent_message, send_message_method, start_time) agent_message.properties.state = "complete" + # Final DB update with the complete message (skip_db_update=False by default) + agent_message = await send_message_method(message=agent_message) except Exception as e: raise ExceptionWithMessageError(agent_message, str(e)) from e return await Message.create(**agent_message.model_dump()) diff --git a/src/lfx/src/lfx/custom/custom_component/component.py b/src/lfx/src/lfx/custom/custom_component/component.py index a48c55100e..555d249019 100644 --- a/src/lfx/src/lfx/custom/custom_component/component.py +++ b/src/lfx/src/lfx/custom/custom_component/component.py @@ -1548,7 +1548,16 @@ class Component(CustomComponent): if not message.sender_name: message.sender_name = MESSAGE_SENDER_NAME_AI - async def send_message(self, message: Message, id_: str | None = None): + async def send_message(self, message: Message, id_: str | None = None, *, skip_db_update: bool = False): + """Send a message with optional database update control. + + Args: + message: The message to send + id_: Optional message ID + skip_db_update: If True, only update in-memory and send event, skip DB write. + Useful during streaming to avoid excessive DB round-trips. + Note: This assumes the message already exists in the database with message.id set. + """ if self._should_skip_message(message): return message @@ -1558,26 +1567,37 @@ class Component(CustomComponent): # Ensure required fields for message storage are set self._ensure_message_required_fields(message) - stored_message = await self._store_message(message) + # If skip_db_update is True and message already has an ID, skip the DB write + # This path is used during agent streaming to avoid excessive DB round-trips + if skip_db_update and message.id: + # Create a fresh Message instance for consistency with normal flow + stored_message = await Message.create(**message.model_dump()) + self._stored_message_id = stored_message.id + # Still send the event to update the client in real-time + # Note: If this fails, we don't need DB cleanup since we didn't write to DB + await self._send_message_event(stored_message, id_=id_) + else: + # Normal flow: store/update in database + stored_message = await self._store_message(message) - self._stored_message_id = stored_message.id - try: - complete_message = "" - if ( - self._should_stream_message(stored_message, message) - and message is not None - and isinstance(message.text, AsyncIterator | Iterator) - ): - complete_message = await self._stream_message(message.text, stored_message) - stored_message.text = complete_message - stored_message = await self._update_stored_message(stored_message) - else: - # Only send message event for non-streaming messages - await self._send_message_event(stored_message, id_=id_) - except Exception: - # remove the message from the database - await delete_message(stored_message.id) - raise + self._stored_message_id = stored_message.id + try: + complete_message = "" + if ( + self._should_stream_message(stored_message, message) + and message is not None + and isinstance(message.text, AsyncIterator | Iterator) + ): + complete_message = await self._stream_message(message.text, stored_message) + stored_message.text = complete_message + stored_message = await self._update_stored_message(stored_message) + else: + # Only send message event for non-streaming messages + await self._send_message_event(stored_message, id_=id_) + except Exception: + # remove the message from the database + await delete_message(stored_message.id) + raise self.status = stored_message return stored_message diff --git a/src/lfx/src/lfx/schema/log.py b/src/lfx/src/lfx/schema/log.py index 5db28cdd33..644391c13c 100644 --- a/src/lfx/src/lfx/schema/log.py +++ b/src/lfx/src/lfx/schema/log.py @@ -34,6 +34,7 @@ class SendMessageFunctionType(Protocol): id_: str | None = None, *, allow_markdown: bool = True, + skip_db_update: bool = False, ) -> Message: ... From c59bfc875ac0fecab2ac292540c241f76598edef Mon Sep 17 00:00:00 2001 From: Mendon Kissling <59585235+mendonk@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:10:36 -0400 Subject: [PATCH 3/6] docs: warnings for dotenv vulnerability (#10138) * dotenv-warnings-and-164-patch Dotenv warnings and 164 patch. * Apply suggestions from code review * remove-pragma-flags * Apply suggestion from @mendonk * Apply suggestion from @mendonk * Apply suggestion from @mendonk --------- Co-authored-by: April I. Murphy <36110273+aimurphy@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- README.md | 1 + SECURITY.md | 15 ++++++++++++++- docs/docs/Support/release-notes.mdx | 13 +++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3324281ecc..76d4d65aec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: hooks: - id: detect-secrets args: ["--baseline", ".secrets.baseline"] - exclude: ^docs/ + exclude: '(^docs/|^SECURITY\.md$)' - repo: local hooks: - id: local-biome-check diff --git a/README.md b/README.md index 6d98c3b0b9..01e8ead7bf 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langflow-ai/langflow) > [!CAUTION] +> - Langflow versions 1.6.0 through 1.6.3 have a critical bug where `.env` files are not read, potentially causing security vulnerabilities. **DO NOT** upgrade to these versions if you use `.env` files for configuration. Instead, upgrade to 1.6.4, which includes a fix for this bug. > - Windows users of Langflow Desktop should **not** use the in-app update feature to upgrade to Langflow version 1.6.0. For upgrade instructions, see [Windows Desktop update issue](https://docs.langflow.org/release-notes#windows-desktop-update-issue). > - Users must update to Langflow >= 1.3 to protect against [CVE-2025-3248](https://nvd.nist.gov/vuln/detail/CVE-2025-3248) > - Users must update to Langflow >= 1.5.1 to protect against [CVE-2025-57760](https://github.com/langflow-ai/langflow/security/advisories/GHSA-4gv9-mp8m-592r) diff --git a/SECURITY.md b/SECURITY.md index 930c0f86c0..38c73733fa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,6 +42,19 @@ We appreciate your efforts in helping us maintain a secure platform and look for ## Known Vulnerabilities +### Environment Variable Loading Bug (Fixed in 1.6.4) + +Langflow versions `1.6.0` through `1.6.3` have a critical bug where environment variables from `.env` files are not being read. This affects all deployments using environment variables for configuration, including security settings. + +**Potential security impact:** +- Environment variables from `.env` files are not read. +- Security configurations like `AUTO_LOGIN=false` may not be applied, potentially allowing users to log in as the default superuser. +- Database credentials, API keys, and other sensitive configuration may not be loaded. + +**DO NOT** upgrade to Langflow versions `1.6.0` through `1.6.3` if you use `.env` files for configuration. Instead, upgrade to version `1.6.4`, which includes a fix for this bug. + +**Fixed in**: Langflow >= 1.6.4 + ### Code Execution Vulnerability (Fixed in 1.3.0) Langflow allows users to define and run **custom code components** through endpoints like `/api/v1/validate/code`. In versions < 1.3.0, this endpoint did not enforce authentication or proper sandboxing, allowing **unauthenticated arbitrary code execution**. @@ -99,4 +112,4 @@ export LANGFLOW_SUPERUSER="" export LANGFLOW_SUPERUSER_PASSWORD="" export LANGFLOW_DATABASE_URL="" # e.g. "postgresql+psycopg://langflow:secure_pass@db.internal:5432/langflow" export LANGFLOW_SECRET_KEY="your-strong-random-secret-key" -``` +``` \ No newline at end of file diff --git a/docs/docs/Support/release-notes.mdx b/docs/docs/Support/release-notes.mdx index 3a69b53473..6c1bc8c67c 100644 --- a/docs/docs/Support/release-notes.mdx +++ b/docs/docs/Support/release-notes.mdx @@ -52,6 +52,19 @@ To avoid the impact of potential breaking changes and test new versions, the Lan Highlights of this release include the following changes. For all changes, see the [Changelog](https://github.com/langflow-ai/langflow/releases). +### Known issue, potential security vulnerability: .env file not loaded in versions 1.6.0 through 1.6.3 {#env-file-bug} + +Langflow versions 1.6.0 through 1.6.3 have a critical bug where environment variables from `.env` files aren't read. +This affects all deployments using environment variables for configuration, including security settings. + +:::Warning Potential security vulnerability +If your `.env` file includes `AUTO_LOGIN=false`, upgrading to the impacted versions causes Langflow to fall back to default settings, potentially giving all users superuser access immediately upon upgrade. +Additionally, database credentials, API keys, and other sensitive configurations can't be loaded from `.env` files. + +_Don't_ upgrade to any Langflow version from 1.6.0 through 1.6.3 if you use `.env` files for configuration. +Instead, upgrade to 1.6.4, which includes a fix for this bug. +::: + ### Known issue: Don't auto-upgrade Windows Desktop {#windows-desktop-update-issue} :::warning From 75d1f8aac9513ec3054217a647b9bf74087073a8 Mon Sep 17 00:00:00 2001 From: Mendon Kissling <59585235+mendonk@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:27:43 -0400 Subject: [PATCH 4/6] docs: fix warning syntax (#10159) warning-syntax --- docs/docs/Support/release-notes.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Support/release-notes.mdx b/docs/docs/Support/release-notes.mdx index 6c1bc8c67c..6d529dd33b 100644 --- a/docs/docs/Support/release-notes.mdx +++ b/docs/docs/Support/release-notes.mdx @@ -57,7 +57,7 @@ For all changes, see the [Changelog](https://github.com/langflow-ai/langflow/rel Langflow versions 1.6.0 through 1.6.3 have a critical bug where environment variables from `.env` files aren't read. This affects all deployments using environment variables for configuration, including security settings. -:::Warning Potential security vulnerability +:::warning Potential security vulnerability If your `.env` file includes `AUTO_LOGIN=false`, upgrading to the impacted versions causes Langflow to fall back to default settings, potentially giving all users superuser access immediately upon upgrade. Additionally, database credentials, API keys, and other sensitive configurations can't be loaded from `.env` files. From 577ccd035831f8fee76be57bd26b5790511f3f62 Mon Sep 17 00:00:00 2001 From: Julia Yan Date: Tue, 7 Oct 2025 16:29:00 -0400 Subject: [PATCH 5/6] Fix: Unable to use images with Ollama (#9949) * Allow ollama to send images * Allow images to work for other models as well * Make param optional * Add typing to the params * reset extraneous value * remove extraneous field in package-lock --------- Co-authored-by: HzaRashid Co-authored-by: Hamza Rashid <74062092+HzaRashid@users.noreply.github.com> Co-authored-by: Carlos Coelho <80289056+carlosrcoelho@users.noreply.github.com> --- src/lfx/src/lfx/base/models/model.py | 2 +- src/lfx/src/lfx/schema/message.py | 10 +++++++--- src/lfx/src/lfx/utils/image.py | 8 +++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lfx/src/lfx/base/models/model.py b/src/lfx/src/lfx/base/models/model.py index e31291a25b..6455762bc0 100644 --- a/src/lfx/src/lfx/base/models/model.py +++ b/src/lfx/src/lfx/base/models/model.py @@ -229,7 +229,7 @@ class LCModelComponent(Component): system_message_added = True runnable = prompt | runnable else: - messages.append(input_value.to_lc_message()) + messages.append(input_value.to_lc_message(self.name)) else: messages.append(HumanMessage(content=input_value)) diff --git a/src/lfx/src/lfx/schema/message.py b/src/lfx/src/lfx/schema/message.py index a43dfe4071..503c3b28c3 100644 --- a/src/lfx/src/lfx/schema/message.py +++ b/src/lfx/src/lfx/schema/message.py @@ -121,9 +121,13 @@ class Message(Data): def to_lc_message( self, + model_name: str | None = None, ) -> BaseMessage: """Converts the Data to a BaseMessage. + Args: + model_name: The model name to use for conversion. Optional. + Returns: BaseMessage: The converted BaseMessage. """ @@ -139,7 +143,7 @@ class Message(Data): if self.sender == MESSAGE_SENDER_USER or not self.sender: if self.files: contents = [{"type": "text", "text": text}] - file_contents = self.get_file_content_dicts() + file_contents = self.get_file_content_dicts(model_name) contents.extend(file_contents) human_message = HumanMessage(content=contents) else: @@ -197,7 +201,7 @@ class Message(Data): return value # Keep this async method for backwards compatibility - def get_file_content_dicts(self): + def get_file_content_dicts(self, model_name: str | None = None): content_dicts = [] try: files = get_file_paths(self.files) @@ -209,7 +213,7 @@ class Message(Data): if isinstance(file, Image): content_dicts.append(file.to_content_dict()) else: - content_dicts.append(create_image_content_dict(file)) + content_dicts.append(create_image_content_dict(file, None, model_name)) return content_dicts def load_lc_prompt(self): diff --git a/src/lfx/src/lfx/utils/image.py b/src/lfx/src/lfx/utils/image.py index 1bb84f40b6..ad224e3706 100644 --- a/src/lfx/src/lfx/utils/image.py +++ b/src/lfx/src/lfx/utils/image.py @@ -56,12 +56,15 @@ def create_data_url(image_path: str | Path, mime_type: str | None = None) -> str @lru_cache(maxsize=50) -def create_image_content_dict(image_path: str | Path, mime_type: str | None = None) -> dict: +def create_image_content_dict( + image_path: str | Path, mime_type: str | None = None, model_name: str | None = None +) -> dict: """Create a content dictionary for multimodal inputs from an image file. Args: image_path: Path to the image file mime_type: MIME type of the image. If None, will be auto-detected + model_name: Optional model parameter to determine content dict structure Returns: Content dictionary with type and image_url fields @@ -70,4 +73,7 @@ def create_image_content_dict(image_path: str | Path, mime_type: str | None = No FileNotFoundError: If the image file doesn't exist """ data_url = create_data_url(image_path, mime_type) + + if model_name == "OllamaModel": + return {"type": "image_url", "source_type": "url", "image_url": data_url} return {"type": "image", "source_type": "url", "url": data_url} From 99fc2cf068a8a72f27852c886e5e14f353cac6cf Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:20:40 -0300 Subject: [PATCH 6/6] feat: adds streamable http instead of sse on mcp client (#10157) * Changed SSE to Streamable_HTTP implementation on backend * Changed SSE to Streamable HTTP on frontend * [autofix.ci] apply automated fixes * Added support for SSE as a fallback * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * added /sse * updated code * created test to test streamable http * Test SSE fallback on HTTP/SSE tab * Changed nvidia remix starter project * [autofix.ci] apply automated fixes * Added changes for backwards compatibility * fixed issues on mcp util * [autofix.ci] apply automated fixes * Fixed tests to have a helper to initialize server, with health check * updated package lock * fixed backend tests * fixed backend test * [autofix.ci] apply automated fixes * format imports * fixed backend test * fixed frontend tests * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * changed http to streamable http * [autofix.ci] apply automated fixes * fixed mcp util test * fixed test * ruff fix * wait timeout on mcp server test --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- package-lock.json | 29 + package.json | 5 + .../starter_projects/Nvidia Remix.json | 4 +- .../tests/unit/base/mcp/test_mcp_util.py | 110 +-- .../components/data/test_mcp_component.py | 32 +- src/frontend/package-lock.json | 737 ++++++++++++++++++ src/frontend/package.json | 1 + .../src/modals/addMcpServerModal/index.tsx | 91 +-- .../extended/features/mcp-server.spec.ts | 273 ++++++- src/lfx/src/lfx/base/mcp/util.py | 335 ++++---- .../lfx/components/agents/mcp_component.py | 15 +- 11 files changed, 1332 insertions(+), 300 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..e924a6fdaf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "langflow", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@types/node": "^24.7.0" + } + }, + "node_modules/@types/node": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", + "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.14.0" + } + }, + "node_modules/undici-types": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..314a61ce85 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@types/node": "^24.7.0" + } +} diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json b/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json index 8fe97fb3ea..c1bf5aee5e 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json @@ -2377,7 +2377,7 @@ "legacy": false, "lf_version": "1.4.2", "metadata": { - "code_hash": "756d1e10d0ca", + "code_hash": "984d87bdb1dc", "dependencies": { "dependencies": [ { @@ -2437,7 +2437,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from __future__ import annotations\n\nimport asyncio\nimport uuid\nfrom typing import Any\n\nfrom langchain_core.tools import StructuredTool # noqa: TC002\n\nfrom lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set\nfrom lfx.base.mcp.util import MCPSseClient, MCPStdioClient, create_input_schema_from_json_schema, update_tools\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.inputs.inputs import InputTypes # noqa: TC001\nfrom lfx.io import BoolInput, DropdownInput, McpInput, MessageTextInput, Output\nfrom lfx.io.schema import flatten_schema, schema_to_langflow_inputs\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service, get_storage_service, session_scope\n\n\nclass MCPToolsComponent(ComponentWithCache):\n schema_inputs: list = []\n tools: list[StructuredTool] = []\n _not_load_actions: bool = False\n _tool_cache: dict = {}\n _last_selected_server: str | None = None # Cache for the last selected server\n\n def __init__(self, **data) -> None:\n super().__init__(**data)\n # Initialize cache keys to avoid CacheMiss when accessing them\n self._ensure_cache_structure()\n\n # Initialize clients with access to the component cache\n self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)\n self.sse_client: MCPSseClient = MCPSseClient(component_cache=self._shared_component_cache)\n\n def _ensure_cache_structure(self):\n \"\"\"Ensure the cache has the required structure.\"\"\"\n # Check if servers key exists and is not CacheMiss\n servers_value = safe_cache_get(self._shared_component_cache, \"servers\")\n if servers_value is None:\n safe_cache_set(self._shared_component_cache, \"servers\", {})\n\n # Check if last_selected_server key exists and is not CacheMiss\n last_server_value = safe_cache_get(self._shared_component_cache, \"last_selected_server\")\n if last_server_value is None:\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", \"\")\n\n default_keys: list[str] = [\n \"code\",\n \"_type\",\n \"tool_mode\",\n \"tool_placeholder\",\n \"mcp_server\",\n \"tool\",\n \"use_cache\",\n ]\n\n display_name = \"MCP Tools\"\n description = \"Connect to an MCP server to use its tools.\"\n documentation: str = \"https://docs.langflow.org/mcp-client\"\n icon = \"Mcp\"\n name = \"MCPTools\"\n\n inputs = [\n McpInput(\n name=\"mcp_server\",\n display_name=\"MCP Server\",\n info=\"Select the MCP Server that will be used by this component\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_cache\",\n display_name=\"Use Cached Server\",\n info=(\n \"Enable caching of MCP Server and tools to improve performance. \"\n \"Disable to always fetch fresh tools and server updates.\"\n ),\n value=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"tool\",\n display_name=\"Tool\",\n options=[],\n value=\"\",\n info=\"Select the tool to execute\",\n show=False,\n required=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"tool_placeholder\",\n display_name=\"Tool Placeholder\",\n info=\"Placeholder for the tool\",\n value=\"\",\n show=False,\n tool_mode=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_output\"),\n ]\n\n async def _validate_schema_inputs(self, tool_obj) -> list[InputTypes]:\n \"\"\"Validate and process schema inputs for a tool.\"\"\"\n try:\n if not tool_obj or not hasattr(tool_obj, \"args_schema\"):\n msg = \"Invalid tool object or missing input schema\"\n raise ValueError(msg)\n\n flat_schema = flatten_schema(tool_obj.args_schema.schema())\n input_schema = create_input_schema_from_json_schema(flat_schema)\n if not input_schema:\n msg = f\"Empty input schema for tool '{tool_obj.name}'\"\n raise ValueError(msg)\n\n schema_inputs = schema_to_langflow_inputs(input_schema)\n if not schema_inputs:\n msg = f\"No input parameters defined for tool '{tool_obj.name}'\"\n await logger.awarning(msg)\n return []\n\n except Exception as e:\n msg = f\"Error validating schema inputs: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return schema_inputs\n\n async def update_tool_list(self, mcp_server_value=None):\n # Accepts mcp_server_value as dict {name, config} or uses self.mcp_server\n mcp_server = mcp_server_value if mcp_server_value is not None else getattr(self, \"mcp_server\", None)\n server_name = None\n server_config_from_value = None\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\")\n server_config_from_value = mcp_server.get(\"config\")\n else:\n server_name = mcp_server\n if not server_name:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config_from_value}\n\n # Check if caching is enabled, default to False\n use_cache = getattr(self, \"use_cache\", False)\n\n # Use shared cache if available and caching is enabled\n cached = None\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n cached = servers_cache.get(server_name) if isinstance(servers_cache, dict) else None\n\n if cached is not None:\n try:\n self.tools = cached[\"tools\"]\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n server_config_from_value = cached[\"config\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by clearing it and continuing to fetch fresh tools\n msg = f\"Unable to use cached data for MCP Server{server_name}: {e}\"\n await logger.awarning(msg)\n # Clear the corrupted cache entry\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict) and server_name in current_servers_cache:\n current_servers_cache.pop(server_name)\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n else:\n return self.tools, {\"name\": server_name, \"config\": server_config_from_value}\n\n try:\n try:\n from langflow.api.v2.mcp import get_server\n from langflow.services.database.models.user.crud import get_user_by_id\n except ImportError as e:\n msg = (\n \"Langflow MCP server functionality is not available. \"\n \"This feature requires the full Langflow installation.\"\n )\n raise ImportError(msg) from e\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching MCP tools.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n\n # Try to get server config from DB/API\n server_config = await get_server(\n server_name,\n current_user,\n db,\n storage_service=get_storage_service(),\n settings_service=get_settings_service(),\n )\n\n # If get_server returns empty but we have a config, use it\n if not server_config and server_config_from_value:\n server_config = server_config_from_value\n\n if not server_config:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config}\n\n _, tool_list, tool_cache = await update_tools(\n server_name=server_name,\n server_config=server_config,\n mcp_stdio_client=self.stdio_client,\n mcp_sse_client=self.sse_client,\n )\n\n self.tool_names = [tool.name for tool in tool_list if hasattr(tool, \"name\")]\n self._tool_cache = tool_cache\n self.tools = tool_list\n\n # Cache the result only if caching is enabled\n if use_cache:\n cache_data = {\n \"tools\": tool_list,\n \"tool_names\": self.tool_names,\n \"tool_cache\": tool_cache,\n \"config\": server_config,\n }\n\n # Safely update the servers cache\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict):\n current_servers_cache[server_name] = cache_data\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise TimeoutError(msg) from e\n except Exception as e:\n msg = f\"Error updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return tool_list, {\"name\": server_name, \"config\": server_config}\n\n async def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Toggle the visibility of connection-specific fields based on the selected mode.\"\"\"\n try:\n if field_name == \"tool\":\n try:\n if len(self.tools) == 0:\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n except ValueError:\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n\n if field_value == \"\":\n return build_config\n tool_obj = None\n for tool in self.tools:\n if tool.name == field_value:\n tool_obj = tool\n break\n if tool_obj is None:\n msg = f\"Tool {field_value} not found in available tools: {self.tools}\"\n await logger.awarning(msg)\n return build_config\n await self._update_tool_config(build_config, field_value)\n except Exception as e:\n build_config[\"tool\"][\"options\"] = []\n msg = f\"Failed to update tools: {e!s}\"\n raise ValueError(msg) from e\n else:\n return build_config\n elif field_name == \"mcp_server\":\n if not field_value:\n build_config[\"tool\"][\"show\"] = False\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool_placeholder\"][\"tool_mode\"] = False\n self.remove_non_default_keys(build_config)\n return build_config\n\n build_config[\"tool_placeholder\"][\"tool_mode\"] = True\n\n current_server_name = field_value.get(\"name\") if isinstance(field_value, dict) else field_value\n _last_selected_server = safe_cache_get(self._shared_component_cache, \"last_selected_server\", \"\")\n\n # To avoid unnecessary updates, only proceed if the server has actually changed\n if (_last_selected_server in (current_server_name, \"\")) and build_config[\"tool\"][\"show\"]:\n if current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None and cached.get(\"tool_names\"):\n cached_tools = cached[\"tool_names\"]\n current_tools = build_config[\"tool\"][\"options\"]\n if current_tools == cached_tools:\n return build_config\n else:\n return build_config\n\n # Determine if \"Tool Mode\" is active by checking if the tool dropdown is hidden.\n is_in_tool_mode = build_config[\"tools_metadata\"][\"show\"]\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n\n # Check if tools are already cached for this server before clearing\n cached_tools = None\n if current_server_name:\n use_cache = getattr(self, \"use_cache\", True)\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None:\n try:\n cached_tools = cached[\"tools\"]\n self.tools = cached_tools\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by ignoring it\n msg = f\"Unable to use cached data for MCP Server,{current_server_name}: {e}\"\n await logger.awarning(msg)\n cached_tools = None\n\n # Only clear tools if we don't have cached tools for the current server\n if not cached_tools:\n self.tools = [] # Clear previous tools only if no cache\n\n self.remove_non_default_keys(build_config) # Clear previous tool inputs\n\n # Only show the tool dropdown if not in tool_mode\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n if cached_tools:\n # Use cached tools to populate options immediately\n build_config[\"tool\"][\"options\"] = [tool.name for tool in cached_tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n else:\n # Show loading state only when we need to fetch tools\n build_config[\"tool\"][\"placeholder\"] = \"Loading tools...\"\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n else:\n # Keep the tool dropdown hidden if in tool_mode\n self._not_load_actions = True\n build_config[\"tool\"][\"show\"] = False\n\n elif field_name == \"tool_mode\":\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool\"][\"show\"] = not bool(field_value) and bool(build_config[\"mcp_server\"])\n self.remove_non_default_keys(build_config)\n self.tool = build_config[\"tool\"][\"value\"]\n if field_value:\n self._not_load_actions = True\n else:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"placeholder\"] = \"Loading tools...\"\n elif field_name == \"tools_metadata\":\n self._not_load_actions = False\n\n except Exception as e:\n msg = f\"Error in update_build_config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return build_config\n\n def get_inputs_for_all_tools(self, tools: list) -> dict:\n \"\"\"Get input schemas for all tools.\"\"\"\n inputs = {}\n for tool in tools:\n if not tool or not hasattr(tool, \"name\"):\n continue\n try:\n flat_schema = flatten_schema(tool.args_schema.schema())\n input_schema = create_input_schema_from_json_schema(flat_schema)\n langflow_inputs = schema_to_langflow_inputs(input_schema)\n inputs[tool.name] = langflow_inputs\n except (AttributeError, ValueError, TypeError, KeyError) as e:\n msg = f\"Error getting inputs for tool {getattr(tool, 'name', 'unknown')}: {e!s}\"\n logger.exception(msg)\n continue\n return inputs\n\n def remove_input_schema_from_build_config(\n self, build_config: dict, tool_name: str, input_schema: dict[list[InputTypes], Any]\n ):\n \"\"\"Remove the input schema for the tool from the build config.\"\"\"\n # Keep only schemas that don't belong to the current tool\n input_schema = {k: v for k, v in input_schema.items() if k != tool_name}\n # Remove all inputs from other tools\n for value in input_schema.values():\n for _input in value:\n if _input.name in build_config:\n build_config.pop(_input.name)\n\n def remove_non_default_keys(self, build_config: dict) -> None:\n \"\"\"Remove non-default keys from the build config.\"\"\"\n for key in list(build_config.keys()):\n if key not in self.default_keys:\n build_config.pop(key)\n\n async def _update_tool_config(self, build_config: dict, tool_name: str) -> None:\n \"\"\"Update tool configuration with proper error handling.\"\"\"\n if not self.tools:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n\n if not tool_name:\n return\n\n tool_obj = next((tool for tool in self.tools if tool.name == tool_name), None)\n if not tool_obj:\n msg = f\"Tool {tool_name} not found in available tools: {self.tools}\"\n self.remove_non_default_keys(build_config)\n build_config[\"tool\"][\"value\"] = \"\"\n await logger.awarning(msg)\n return\n\n try:\n # Store current values before removing inputs\n current_values = {}\n for key, value in build_config.items():\n if key not in self.default_keys and isinstance(value, dict) and \"value\" in value:\n current_values[key] = value[\"value\"]\n\n # Get all tool inputs and remove old ones\n input_schema_for_all_tools = self.get_inputs_for_all_tools(self.tools)\n self.remove_input_schema_from_build_config(build_config, tool_name, input_schema_for_all_tools)\n\n # Get and validate new inputs\n self.schema_inputs = await self._validate_schema_inputs(tool_obj)\n if not self.schema_inputs:\n msg = f\"No input parameters to configure for tool '{tool_name}'\"\n await logger.ainfo(msg)\n return\n\n # Add new inputs to build config\n for schema_input in self.schema_inputs:\n if not schema_input or not hasattr(schema_input, \"name\"):\n msg = \"Invalid schema input detected, skipping\"\n await logger.awarning(msg)\n continue\n\n try:\n name = schema_input.name\n input_dict = schema_input.to_dict()\n input_dict.setdefault(\"value\", None)\n input_dict.setdefault(\"required\", True)\n\n build_config[name] = input_dict\n\n # Preserve existing value if the parameter name exists in current_values\n if name in current_values:\n build_config[name][\"value\"] = current_values[name]\n\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error processing schema input {schema_input}: {e!s}\"\n await logger.aexception(msg)\n continue\n except ValueError as e:\n msg = f\"Schema validation error for tool {tool_name}: {e!s}\"\n await logger.aexception(msg)\n self.schema_inputs = []\n return\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error updating tool config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n async def build_output(self) -> DataFrame:\n \"\"\"Build output with improved error handling and validation.\"\"\"\n try:\n self.tools, _ = await self.update_tool_list()\n if self.tool != \"\":\n # Set session context for persistent MCP sessions using Langflow session ID\n session_context = self._get_session_context()\n if session_context:\n self.stdio_client.set_session_context(session_context)\n self.sse_client.set_session_context(session_context)\n\n exec_tool = self._tool_cache[self.tool]\n tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool]\n kwargs = {}\n for arg in tool_args:\n value = getattr(self, arg.name, None)\n if value is not None:\n if isinstance(value, Message):\n kwargs[arg.name] = value.text\n else:\n kwargs[arg.name] = value\n\n unflattened_kwargs = maybe_unflatten_dict(kwargs)\n\n output = await exec_tool.coroutine(**unflattened_kwargs)\n\n tool_content = []\n for item in output.content:\n item_dict = item.model_dump()\n tool_content.append(item_dict)\n return DataFrame(data=tool_content)\n return DataFrame(data=[{\"error\": \"You must select a tool\"}])\n except Exception as e:\n msg = f\"Error in build_output: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n def _get_session_context(self) -> str | None:\n \"\"\"Get the Langflow session ID for MCP session caching.\"\"\"\n # Try to get session ID from the component's execution context\n if hasattr(self, \"graph\") and hasattr(self.graph, \"session_id\"):\n session_id = self.graph.session_id\n # Include server name to ensure different servers get different sessions\n server_name = \"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\", \"\")\n elif mcp_server:\n server_name = str(mcp_server)\n return f\"{session_id}_{server_name}\" if session_id else None\n return None\n\n async def _get_tools(self):\n \"\"\"Get cached tools or update if necessary.\"\"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if not self._not_load_actions:\n tools, _ = await self.update_tool_list(mcp_server)\n return tools\n return []\n" + "value": "from __future__ import annotations\n\nimport asyncio\nimport uuid\nfrom typing import Any\n\nfrom langchain_core.tools import StructuredTool # noqa: TC002\n\nfrom lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set\nfrom lfx.base.mcp.util import (\n MCPStdioClient,\n MCPStreamableHttpClient,\n create_input_schema_from_json_schema,\n update_tools,\n)\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.inputs.inputs import InputTypes # noqa: TC001\nfrom lfx.io import BoolInput, DropdownInput, McpInput, MessageTextInput, Output\nfrom lfx.io.schema import flatten_schema, schema_to_langflow_inputs\nfrom lfx.log.logger import logger\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service, get_storage_service, session_scope\n\n\nclass MCPToolsComponent(ComponentWithCache):\n schema_inputs: list = []\n tools: list[StructuredTool] = []\n _not_load_actions: bool = False\n _tool_cache: dict = {}\n _last_selected_server: str | None = None # Cache for the last selected server\n\n def __init__(self, **data) -> None:\n super().__init__(**data)\n # Initialize cache keys to avoid CacheMiss when accessing them\n self._ensure_cache_structure()\n\n # Initialize clients with access to the component cache\n self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)\n self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(\n component_cache=self._shared_component_cache\n )\n\n def _ensure_cache_structure(self):\n \"\"\"Ensure the cache has the required structure.\"\"\"\n # Check if servers key exists and is not CacheMiss\n servers_value = safe_cache_get(self._shared_component_cache, \"servers\")\n if servers_value is None:\n safe_cache_set(self._shared_component_cache, \"servers\", {})\n\n # Check if last_selected_server key exists and is not CacheMiss\n last_server_value = safe_cache_get(self._shared_component_cache, \"last_selected_server\")\n if last_server_value is None:\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", \"\")\n\n default_keys: list[str] = [\n \"code\",\n \"_type\",\n \"tool_mode\",\n \"tool_placeholder\",\n \"mcp_server\",\n \"tool\",\n \"use_cache\",\n ]\n\n display_name = \"MCP Tools\"\n description = \"Connect to an MCP server to use its tools.\"\n documentation: str = \"https://docs.langflow.org/mcp-client\"\n icon = \"Mcp\"\n name = \"MCPTools\"\n\n inputs = [\n McpInput(\n name=\"mcp_server\",\n display_name=\"MCP Server\",\n info=\"Select the MCP Server that will be used by this component\",\n real_time_refresh=True,\n ),\n BoolInput(\n name=\"use_cache\",\n display_name=\"Use Cached Server\",\n info=(\n \"Enable caching of MCP Server and tools to improve performance. \"\n \"Disable to always fetch fresh tools and server updates.\"\n ),\n value=False,\n advanced=True,\n ),\n DropdownInput(\n name=\"tool\",\n display_name=\"Tool\",\n options=[],\n value=\"\",\n info=\"Select the tool to execute\",\n show=False,\n required=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"tool_placeholder\",\n display_name=\"Tool Placeholder\",\n info=\"Placeholder for the tool\",\n value=\"\",\n show=False,\n tool_mode=False,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_output\"),\n ]\n\n async def _validate_schema_inputs(self, tool_obj) -> list[InputTypes]:\n \"\"\"Validate and process schema inputs for a tool.\"\"\"\n try:\n if not tool_obj or not hasattr(tool_obj, \"args_schema\"):\n msg = \"Invalid tool object or missing input schema\"\n raise ValueError(msg)\n\n flat_schema = flatten_schema(tool_obj.args_schema.schema())\n input_schema = create_input_schema_from_json_schema(flat_schema)\n if not input_schema:\n msg = f\"Empty input schema for tool '{tool_obj.name}'\"\n raise ValueError(msg)\n\n schema_inputs = schema_to_langflow_inputs(input_schema)\n if not schema_inputs:\n msg = f\"No input parameters defined for tool '{tool_obj.name}'\"\n await logger.awarning(msg)\n return []\n\n except Exception as e:\n msg = f\"Error validating schema inputs: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return schema_inputs\n\n async def update_tool_list(self, mcp_server_value=None):\n # Accepts mcp_server_value as dict {name, config} or uses self.mcp_server\n mcp_server = mcp_server_value if mcp_server_value is not None else getattr(self, \"mcp_server\", None)\n server_name = None\n server_config_from_value = None\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\")\n server_config_from_value = mcp_server.get(\"config\")\n else:\n server_name = mcp_server\n if not server_name:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config_from_value}\n\n # Check if caching is enabled, default to False\n use_cache = getattr(self, \"use_cache\", False)\n\n # Use shared cache if available and caching is enabled\n cached = None\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n cached = servers_cache.get(server_name) if isinstance(servers_cache, dict) else None\n\n if cached is not None:\n try:\n self.tools = cached[\"tools\"]\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n server_config_from_value = cached[\"config\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by clearing it and continuing to fetch fresh tools\n msg = f\"Unable to use cached data for MCP Server{server_name}: {e}\"\n await logger.awarning(msg)\n # Clear the corrupted cache entry\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict) and server_name in current_servers_cache:\n current_servers_cache.pop(server_name)\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n else:\n return self.tools, {\"name\": server_name, \"config\": server_config_from_value}\n\n try:\n try:\n from langflow.api.v2.mcp import get_server\n from langflow.services.database.models.user.crud import get_user_by_id\n except ImportError as e:\n msg = (\n \"Langflow MCP server functionality is not available. \"\n \"This feature requires the full Langflow installation.\"\n )\n raise ImportError(msg) from e\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching MCP tools.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n\n # Try to get server config from DB/API\n server_config = await get_server(\n server_name,\n current_user,\n db,\n storage_service=get_storage_service(),\n settings_service=get_settings_service(),\n )\n\n # If get_server returns empty but we have a config, use it\n if not server_config and server_config_from_value:\n server_config = server_config_from_value\n\n if not server_config:\n self.tools = []\n return [], {\"name\": server_name, \"config\": server_config}\n\n _, tool_list, tool_cache = await update_tools(\n server_name=server_name,\n server_config=server_config,\n mcp_stdio_client=self.stdio_client,\n mcp_streamable_http_client=self.streamable_http_client,\n )\n\n self.tool_names = [tool.name for tool in tool_list if hasattr(tool, \"name\")]\n self._tool_cache = tool_cache\n self.tools = tool_list\n\n # Cache the result only if caching is enabled\n if use_cache:\n cache_data = {\n \"tools\": tool_list,\n \"tool_names\": self.tool_names,\n \"tool_cache\": tool_cache,\n \"config\": server_config,\n }\n\n # Safely update the servers cache\n current_servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(current_servers_cache, dict):\n current_servers_cache[server_name] = cache_data\n safe_cache_set(self._shared_component_cache, \"servers\", current_servers_cache)\n\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise TimeoutError(msg) from e\n except Exception as e:\n msg = f\"Error updating tool list: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return tool_list, {\"name\": server_name, \"config\": server_config}\n\n async def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Toggle the visibility of connection-specific fields based on the selected mode.\"\"\"\n try:\n if field_name == \"tool\":\n try:\n if len(self.tools) == 0:\n try:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n build_config[\"tool\"][\"options\"] = [tool.name for tool in self.tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n except (TimeoutError, asyncio.TimeoutError) as e:\n msg = f\"Timeout updating tool list: {e!s}\"\n await logger.aexception(msg)\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Timeout on MCP server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n except ValueError:\n if not build_config[\"tools_metadata\"][\"show\"]:\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"Error on MCP Server\"\n else:\n build_config[\"tool\"][\"show\"] = False\n\n if field_value == \"\":\n return build_config\n tool_obj = None\n for tool in self.tools:\n if tool.name == field_value:\n tool_obj = tool\n break\n if tool_obj is None:\n msg = f\"Tool {field_value} not found in available tools: {self.tools}\"\n await logger.awarning(msg)\n return build_config\n await self._update_tool_config(build_config, field_value)\n except Exception as e:\n build_config[\"tool\"][\"options\"] = []\n msg = f\"Failed to update tools: {e!s}\"\n raise ValueError(msg) from e\n else:\n return build_config\n elif field_name == \"mcp_server\":\n if not field_value:\n build_config[\"tool\"][\"show\"] = False\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = \"\"\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool_placeholder\"][\"tool_mode\"] = False\n self.remove_non_default_keys(build_config)\n return build_config\n\n build_config[\"tool_placeholder\"][\"tool_mode\"] = True\n\n current_server_name = field_value.get(\"name\") if isinstance(field_value, dict) else field_value\n _last_selected_server = safe_cache_get(self._shared_component_cache, \"last_selected_server\", \"\")\n\n # To avoid unnecessary updates, only proceed if the server has actually changed\n if (_last_selected_server in (current_server_name, \"\")) and build_config[\"tool\"][\"show\"]:\n if current_server_name:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None and cached.get(\"tool_names\"):\n cached_tools = cached[\"tool_names\"]\n current_tools = build_config[\"tool\"][\"options\"]\n if current_tools == cached_tools:\n return build_config\n else:\n return build_config\n\n # Determine if \"Tool Mode\" is active by checking if the tool dropdown is hidden.\n is_in_tool_mode = build_config[\"tools_metadata\"][\"show\"]\n safe_cache_set(self._shared_component_cache, \"last_selected_server\", current_server_name)\n\n # Check if tools are already cached for this server before clearing\n cached_tools = None\n if current_server_name:\n use_cache = getattr(self, \"use_cache\", True)\n if use_cache:\n servers_cache = safe_cache_get(self._shared_component_cache, \"servers\", {})\n if isinstance(servers_cache, dict):\n cached = servers_cache.get(current_server_name)\n if cached is not None:\n try:\n cached_tools = cached[\"tools\"]\n self.tools = cached_tools\n self.tool_names = cached[\"tool_names\"]\n self._tool_cache = cached[\"tool_cache\"]\n except (TypeError, KeyError, AttributeError) as e:\n # Handle corrupted cache data by ignoring it\n msg = f\"Unable to use cached data for MCP Server,{current_server_name}: {e}\"\n await logger.awarning(msg)\n cached_tools = None\n\n # Only clear tools if we don't have cached tools for the current server\n if not cached_tools:\n self.tools = [] # Clear previous tools only if no cache\n\n self.remove_non_default_keys(build_config) # Clear previous tool inputs\n\n # Only show the tool dropdown if not in tool_mode\n if not is_in_tool_mode:\n build_config[\"tool\"][\"show\"] = True\n if cached_tools:\n # Use cached tools to populate options immediately\n build_config[\"tool\"][\"options\"] = [tool.name for tool in cached_tools]\n build_config[\"tool\"][\"placeholder\"] = \"Select a tool\"\n else:\n # Show loading state only when we need to fetch tools\n build_config[\"tool\"][\"placeholder\"] = \"Loading tools...\"\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n else:\n # Keep the tool dropdown hidden if in tool_mode\n self._not_load_actions = True\n build_config[\"tool\"][\"show\"] = False\n\n elif field_name == \"tool_mode\":\n build_config[\"tool\"][\"placeholder\"] = \"\"\n build_config[\"tool\"][\"show\"] = not bool(field_value) and bool(build_config[\"mcp_server\"])\n self.remove_non_default_keys(build_config)\n self.tool = build_config[\"tool\"][\"value\"]\n if field_value:\n self._not_load_actions = True\n else:\n build_config[\"tool\"][\"value\"] = uuid.uuid4()\n build_config[\"tool\"][\"options\"] = []\n build_config[\"tool\"][\"show\"] = True\n build_config[\"tool\"][\"placeholder\"] = \"Loading tools...\"\n elif field_name == \"tools_metadata\":\n self._not_load_actions = False\n\n except Exception as e:\n msg = f\"Error in update_build_config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n else:\n return build_config\n\n def get_inputs_for_all_tools(self, tools: list) -> dict:\n \"\"\"Get input schemas for all tools.\"\"\"\n inputs = {}\n for tool in tools:\n if not tool or not hasattr(tool, \"name\"):\n continue\n try:\n flat_schema = flatten_schema(tool.args_schema.schema())\n input_schema = create_input_schema_from_json_schema(flat_schema)\n langflow_inputs = schema_to_langflow_inputs(input_schema)\n inputs[tool.name] = langflow_inputs\n except (AttributeError, ValueError, TypeError, KeyError) as e:\n msg = f\"Error getting inputs for tool {getattr(tool, 'name', 'unknown')}: {e!s}\"\n logger.exception(msg)\n continue\n return inputs\n\n def remove_input_schema_from_build_config(\n self, build_config: dict, tool_name: str, input_schema: dict[list[InputTypes], Any]\n ):\n \"\"\"Remove the input schema for the tool from the build config.\"\"\"\n # Keep only schemas that don't belong to the current tool\n input_schema = {k: v for k, v in input_schema.items() if k != tool_name}\n # Remove all inputs from other tools\n for value in input_schema.values():\n for _input in value:\n if _input.name in build_config:\n build_config.pop(_input.name)\n\n def remove_non_default_keys(self, build_config: dict) -> None:\n \"\"\"Remove non-default keys from the build config.\"\"\"\n for key in list(build_config.keys()):\n if key not in self.default_keys:\n build_config.pop(key)\n\n async def _update_tool_config(self, build_config: dict, tool_name: str) -> None:\n \"\"\"Update tool configuration with proper error handling.\"\"\"\n if not self.tools:\n self.tools, build_config[\"mcp_server\"][\"value\"] = await self.update_tool_list()\n\n if not tool_name:\n return\n\n tool_obj = next((tool for tool in self.tools if tool.name == tool_name), None)\n if not tool_obj:\n msg = f\"Tool {tool_name} not found in available tools: {self.tools}\"\n self.remove_non_default_keys(build_config)\n build_config[\"tool\"][\"value\"] = \"\"\n await logger.awarning(msg)\n return\n\n try:\n # Store current values before removing inputs\n current_values = {}\n for key, value in build_config.items():\n if key not in self.default_keys and isinstance(value, dict) and \"value\" in value:\n current_values[key] = value[\"value\"]\n\n # Get all tool inputs and remove old ones\n input_schema_for_all_tools = self.get_inputs_for_all_tools(self.tools)\n self.remove_input_schema_from_build_config(build_config, tool_name, input_schema_for_all_tools)\n\n # Get and validate new inputs\n self.schema_inputs = await self._validate_schema_inputs(tool_obj)\n if not self.schema_inputs:\n msg = f\"No input parameters to configure for tool '{tool_name}'\"\n await logger.ainfo(msg)\n return\n\n # Add new inputs to build config\n for schema_input in self.schema_inputs:\n if not schema_input or not hasattr(schema_input, \"name\"):\n msg = \"Invalid schema input detected, skipping\"\n await logger.awarning(msg)\n continue\n\n try:\n name = schema_input.name\n input_dict = schema_input.to_dict()\n input_dict.setdefault(\"value\", None)\n input_dict.setdefault(\"required\", True)\n\n build_config[name] = input_dict\n\n # Preserve existing value if the parameter name exists in current_values\n if name in current_values:\n build_config[name][\"value\"] = current_values[name]\n\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error processing schema input {schema_input}: {e!s}\"\n await logger.aexception(msg)\n continue\n except ValueError as e:\n msg = f\"Schema validation error for tool {tool_name}: {e!s}\"\n await logger.aexception(msg)\n self.schema_inputs = []\n return\n except (AttributeError, KeyError, TypeError) as e:\n msg = f\"Error updating tool config: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n async def build_output(self) -> DataFrame:\n \"\"\"Build output with improved error handling and validation.\"\"\"\n try:\n self.tools, _ = await self.update_tool_list()\n if self.tool != \"\":\n # Set session context for persistent MCP sessions using Langflow session ID\n session_context = self._get_session_context()\n if session_context:\n self.stdio_client.set_session_context(session_context)\n self.streamable_http_client.set_session_context(session_context)\n\n exec_tool = self._tool_cache[self.tool]\n tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool]\n kwargs = {}\n for arg in tool_args:\n value = getattr(self, arg.name, None)\n if value is not None:\n if isinstance(value, Message):\n kwargs[arg.name] = value.text\n else:\n kwargs[arg.name] = value\n\n unflattened_kwargs = maybe_unflatten_dict(kwargs)\n\n output = await exec_tool.coroutine(**unflattened_kwargs)\n\n tool_content = []\n for item in output.content:\n item_dict = item.model_dump()\n tool_content.append(item_dict)\n return DataFrame(data=tool_content)\n return DataFrame(data=[{\"error\": \"You must select a tool\"}])\n except Exception as e:\n msg = f\"Error in build_output: {e!s}\"\n await logger.aexception(msg)\n raise ValueError(msg) from e\n\n def _get_session_context(self) -> str | None:\n \"\"\"Get the Langflow session ID for MCP session caching.\"\"\"\n # Try to get session ID from the component's execution context\n if hasattr(self, \"graph\") and hasattr(self.graph, \"session_id\"):\n session_id = self.graph.session_id\n # Include server name to ensure different servers get different sessions\n server_name = \"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if isinstance(mcp_server, dict):\n server_name = mcp_server.get(\"name\", \"\")\n elif mcp_server:\n server_name = str(mcp_server)\n return f\"{session_id}_{server_name}\" if session_id else None\n return None\n\n async def _get_tools(self):\n \"\"\"Get cached tools or update if necessary.\"\"\"\n mcp_server = getattr(self, \"mcp_server\", None)\n if not self._not_load_actions:\n tools, _ = await self.update_tool_list(mcp_server)\n return tools\n return []\n" }, "mcp_server": { "_input_type": "McpInput", diff --git a/src/backend/tests/unit/base/mcp/test_mcp_util.py b/src/backend/tests/unit/base/mcp/test_mcp_util.py index 9bd4d04990..ffeb95b0aa 100644 --- a/src/backend/tests/unit/base/mcp/test_mcp_util.py +++ b/src/backend/tests/unit/base/mcp/test_mcp_util.py @@ -13,7 +13,14 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from lfx.base.mcp import util -from lfx.base.mcp.util import MCPSessionManager, MCPSseClient, MCPStdioClient, _process_headers, validate_headers +from lfx.base.mcp.util import ( + MCPSessionManager, + MCPSseClient, + MCPStdioClient, + MCPStreamableHttpClient, + _process_headers, + validate_headers, +) class TestMCPSessionManager: @@ -186,11 +193,11 @@ class TestHeaderValidation: assert result == {"safe-header": "safe-value"} -class TestSSEHeaderIntegration: - """Integration test to verify headers are properly passed through the entire SSE flow.""" +class TestStreamableHTTPHeaderIntegration: + """Integration test to verify headers are properly passed through the entire StreamableHTTP flow.""" async def test_headers_processing(self): - """Test that headers flow properly from server config through to SSE client connection.""" + """Test that headers flow properly from server config through to StreamableHTTP client connection.""" # Test the header processing function directly headers_input = [ {"key": "Authorization", "value": "Bearer test-token"}, @@ -231,15 +238,15 @@ class TestSSEHeaderIntegration: result = _process_headers(invalid_headers) assert result == {"valid-header": "good"} - async def test_sse_client_header_storage(self): + async def test_streamable_http_client_header_storage(self): """Test that SSE client properly stores headers in connection params.""" - sse_client = MCPSseClient() + streamable_http_client = MCPStreamableHttpClient() test_url = "http://test.url" test_headers = {"Authorization": "Bearer test123", "Custom": "value"} # Test that headers are properly stored in connection params # Set connection params as a dict like the implementation expects - sse_client._connection_params = { + streamable_http_client._connection_params = { "url": test_url, "headers": test_headers, "timeout_seconds": 30, @@ -247,8 +254,8 @@ class TestSSEHeaderIntegration: } # Verify headers are stored - assert sse_client._connection_params["url"] == test_url - assert sse_client._connection_params["headers"] == test_headers + assert streamable_http_client._connection_params["url"] == test_url + assert streamable_http_client._connection_params["headers"] == test_headers class TestFieldNameConversion: @@ -930,22 +937,22 @@ class TestMCPStdioClientWithEverythingServer: await stdio_client.disconnect() -class TestMCPSseClientWithDeepWikiServer: +class TestMCPStreamableHttpClientWithDeepWikiServer: """Test MCPSseClient with the DeepWiki MCP server.""" @pytest.fixture - def sse_client(self): + def streamable_http_client(self): """Create an SSE client for testing.""" - return MCPSseClient() + return MCPStreamableHttpClient() @pytest.mark.asyncio - async def test_connect_to_deepwiki_server(self, sse_client): + async def test_connect_to_deepwiki_server(self, streamable_http_client): """Test connecting to the DeepWiki MCP server.""" url = "https://mcp.deepwiki.com/sse" try: # Connect to the server - tools = await sse_client.connect_to_server(url) + tools = await streamable_http_client.connect_to_server(url) # Verify tools were returned assert len(tools) > 0 @@ -961,16 +968,16 @@ class TestMCPSseClientWithDeepWikiServer: # If the server is not accessible, skip the test pytest.skip(f"DeepWiki server not accessible: {e}") finally: - await sse_client.disconnect() + await streamable_http_client.disconnect() @pytest.mark.asyncio - async def test_run_wiki_structure_tool(self, sse_client): + async def test_run_wiki_structure_tool(self, streamable_http_client): """Test running the read_wiki_structure tool.""" url = "https://mcp.deepwiki.com/sse" try: # Connect to the server - tools = await sse_client.connect_to_server(url) + tools = await streamable_http_client.connect_to_server(url) # Find the read_wiki_structure tool wiki_tool = None @@ -982,7 +989,7 @@ class TestMCPSseClientWithDeepWikiServer: assert wiki_tool is not None, "read_wiki_structure tool not found" # Run the tool with a test repository (use repoName as expected by the API) - result = await sse_client.run_tool("read_wiki_structure", {"repoName": "microsoft/vscode"}) + result = await streamable_http_client.run_tool("read_wiki_structure", {"repoName": "microsoft/vscode"}) # Verify the result assert result is not None @@ -993,16 +1000,16 @@ class TestMCPSseClientWithDeepWikiServer: # If the server is not accessible or the tool fails, skip the test pytest.skip(f"DeepWiki server test failed: {e}") finally: - await sse_client.disconnect() + await streamable_http_client.disconnect() @pytest.mark.asyncio - async def test_ask_question_tool(self, sse_client): + async def test_ask_question_tool(self, streamable_http_client): """Test running the ask_question tool.""" url = "https://mcp.deepwiki.com/sse" try: # Connect to the server - tools = await sse_client.connect_to_server(url) + tools = await streamable_http_client.connect_to_server(url) # Find the ask_question tool ask_tool = None @@ -1014,7 +1021,7 @@ class TestMCPSseClientWithDeepWikiServer: assert ask_tool is not None, "ask_question tool not found" # Run the tool with a test question (use repoName as expected by the API) - result = await sse_client.run_tool( + result = await streamable_http_client.run_tool( "ask_question", {"repoName": "microsoft/vscode", "question": "What is VS Code?"} ) @@ -1027,14 +1034,14 @@ class TestMCPSseClientWithDeepWikiServer: # If the server is not accessible or the tool fails, skip the test pytest.skip(f"DeepWiki server test failed: {e}") finally: - await sse_client.disconnect() + await streamable_http_client.disconnect() @pytest.mark.asyncio - async def test_url_validation(self, sse_client): + async def test_url_validation(self, streamable_http_client): """Test URL validation for SSE connections.""" # Test valid URL valid_url = "https://mcp.deepwiki.com/sse" - is_valid, error = await sse_client.validate_url(valid_url) + is_valid, error = await streamable_http_client.validate_url(valid_url) # Either valid or accessible, or rate-limited (429) which indicates server is reachable if not is_valid and "429" in error: # Rate limiting indicates the server is accessible but limiting requests @@ -1044,29 +1051,10 @@ class TestMCPSseClientWithDeepWikiServer: # Test invalid URL invalid_url = "not_a_url" - is_valid, error = await sse_client.validate_url(invalid_url) + is_valid, error = await streamable_http_client.validate_url(invalid_url) assert not is_valid assert error != "" - @pytest.mark.asyncio - async def test_redirect_handling(self, sse_client): - """Test redirect handling for SSE connections.""" - # Test with the DeepWiki URL - url = "https://mcp.deepwiki.com/sse" - - try: - # Check for redirects - final_url = await sse_client.pre_check_redirect(url) - - # Should return a URL (either original or redirected) - assert final_url is not None - assert isinstance(final_url, str) - assert final_url.startswith("http") - - except Exception as e: - # If the server is not accessible, skip the test - pytest.skip(f"DeepWiki server not accessible for redirect test: {e}") - @pytest.fixture def mock_tool(self): """Create a mock MCP tool.""" @@ -1117,14 +1105,14 @@ class TestMCPSseClientUnit: mock_response.status_code = 200 mock_client.return_value.__aenter__.return_value.get.return_value = mock_response - is_valid, error_msg = await sse_client.validate_url("http://test.url", {}) + is_valid, error_msg = await sse_client.validate_url("http://test.url") assert is_valid is True assert error_msg == "" async def test_validate_url_invalid_format(self, sse_client): """Test URL validation with invalid format.""" - is_valid, error_msg = await sse_client.validate_url("invalid-url", {}) + is_valid, error_msg = await sse_client.validate_url("invalid-url") assert is_valid is False assert "Invalid URL format" in error_msg @@ -1136,7 +1124,7 @@ class TestMCPSseClientUnit: mock_response.status_code = 404 mock_client.return_value.__aenter__.return_value.get.return_value = mock_response - is_valid, error_msg = await sse_client.validate_url("http://test.url", {}) + is_valid, error_msg = await sse_client.validate_url("http://test.url") assert is_valid is True assert error_msg == "" @@ -1149,7 +1137,6 @@ class TestMCPSseClientUnit: with ( patch.object(sse_client, "validate_url", return_value=(True, "")), - patch.object(sse_client, "pre_check_redirect", return_value=test_url), patch.object(sse_client, "_get_or_create_session") as mock_get_session, ): # Mock session @@ -1194,29 +1181,10 @@ class TestMCPSseClientUnit: result_session = await sse_client._get_or_create_session() # Verify session manager was called with correct parameters including normalized headers - mock_manager.get_session.assert_called_once_with("test_context", sse_client._connection_params, "sse") - assert result_session == mock_session - - async def test_pre_check_redirect_with_headers(self, sse_client): - """Test pre-check redirect functionality with custom headers.""" - test_url = "http://test.url" - redirect_url = "http://redirect.url" - # Use pre-validated headers since pre_check_redirect expects already validated headers - test_headers = {"authorization": "Bearer token123"} # already normalized - - with patch("httpx.AsyncClient") as mock_client: - mock_response = MagicMock() - mock_response.status_code = 307 - mock_response.headers.get.return_value = redirect_url - mock_client.return_value.__aenter__.return_value.get.return_value = mock_response - - result = await sse_client.pre_check_redirect(test_url, test_headers) - - assert result == redirect_url - # Verify validated headers were passed to the request - mock_client.return_value.__aenter__.return_value.get.assert_called_with( - test_url, timeout=2.0, headers={"Accept": "text/event-stream", **test_headers} + mock_manager.get_session.assert_called_once_with( + "test_context", sse_client._connection_params, "streamable_http" ) + assert result_session == mock_session async def test_run_tool_with_retry_on_connection_error(self, sse_client): """Test that run_tool retries on connection errors.""" diff --git a/src/backend/tests/unit/components/data/test_mcp_component.py b/src/backend/tests/unit/components/data/test_mcp_component.py index b21a8f0418..42ef9b9645 100644 --- a/src/backend/tests/unit/components/data/test_mcp_component.py +++ b/src/backend/tests/unit/components/data/test_mcp_component.py @@ -2,14 +2,14 @@ This test suite validates the MCP component functionality using real MCP servers: - Everything server (stdio mode) - provides echo and other tools -- DeepWiki server (SSE mode) - provides wiki-related tools +- HTTP/SSE servers (streamable HTTP mode) - provides various tools """ import shutil from unittest.mock import AsyncMock, MagicMock, patch import pytest -from lfx.base.mcp.util import MCPSessionManager, MCPSseClient, MCPStdioClient +from lfx.base.mcp.util import MCPSessionManager, MCPStdioClient, MCPStreamableHttpClient from lfx.components.agents.mcp_component import MCPToolsComponent from tests.base import ComponentTestBaseWithoutClient, VersionComponentMapping @@ -45,9 +45,9 @@ class TestMCPToolsComponent(ComponentTestBaseWithoutClient): # Check that the component has the expected attributes assert hasattr(component, "stdio_client") - assert hasattr(component, "sse_client") + assert hasattr(component, "streamable_http_client") assert isinstance(component.stdio_client, MCPStdioClient) - assert isinstance(component.sse_client, MCPSseClient) + assert isinstance(component.streamable_http_client, MCPStreamableHttpClient) # Check that the component has a session manager session_manager = component.stdio_client._get_session_manager() @@ -87,11 +87,11 @@ class TestMCPToolsComponentIntegration: pytest.skip(f"Everything server not accessible: {e}") @pytest.mark.asyncio - async def test_sse_mode_integration(self, component): - """Test the component in SSE mode with DeepWiki server.""" - # Configure for SSE mode - component.mode = "SSE" - component.sse_url = "https://mcp.deepwiki.com/sse" + async def test_streamable_http_mode_integration(self, component): + """Test the component in Streamable HTTP mode with DeepWiki server.""" + # Configure for Streamable HTTP mode + component.mode = "Streamable HTTP" + component.streamable_http_url = "https://mcp.deepwiki.com/mcp" try: # Mock the update_tool_list method to simulate server connection @@ -111,27 +111,27 @@ class TestMCPToolsComponentIntegration: @pytest.mark.asyncio async def test_session_context_setting(self, component): """Test that session context is properly set.""" - # Set session context + # Set session context on both clients component.stdio_client.set_session_context("test_context") - component.sse_client.set_session_context("test_context") + component.streamable_http_client.set_session_context("test_context") # Verify context was set assert component.stdio_client._session_context == "test_context" - assert component.sse_client._session_context == "test_context" + assert component.streamable_http_client._session_context == "test_context" @pytest.mark.asyncio async def test_session_manager_sharing(self, component): """Test that session managers are shared through component cache.""" - # Get session managers + # Get session managers from both clients stdio_manager = component.stdio_client._get_session_manager() - sse_manager = component.sse_client._get_session_manager() + http_manager = component.streamable_http_client._get_session_manager() # Both should be MCPSessionManager instances assert isinstance(stdio_manager, MCPSessionManager) - assert isinstance(sse_manager, MCPSessionManager) + assert isinstance(http_manager, MCPSessionManager) # They should be the same instance (shared through cache) - assert stdio_manager is sse_manager + assert stdio_manager is http_manager class TestMCPComponentErrorHandling: diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index c034e483b6..484acf95b9 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -98,6 +98,7 @@ "devDependencies": { "@biomejs/biome": "2.1.1", "@jest/types": "^30.0.1", + "@modelcontextprotocol/server-everything": "^0.6.2", "@playwright/test": "^1.52.0", "@swc/cli": "^0.5.2", "@swc/core": "^1.6.1", @@ -3898,6 +3899,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.1.tgz", + "integrity": "sha512-slLdFaxQJ9AlRg+hw28iiTtGvShAOgOKXcD0F91nUcRYiOMuS9ZBYjcdNZRXW9G5JQ511GRTdUy1zQVZDpJ+4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "raw-body": "^3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@modelcontextprotocol/server-everything": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-everything/-/server-everything-0.6.2.tgz", + "integrity": "sha512-8ILXxbM8kBWbrtEZoCBYqvAPRyPHhayo4aS2llIPt9oi55o4EUj/90xTFeXuyiM3ReQR/fov3ZmpCy/vC+svUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "1.0.1", + "express": "^4.21.1", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.5" + }, + "bin": { + "mcp-server-everything": "dist/index.js" + } + }, "node_modules/@napi-rs/nice": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", @@ -8307,6 +8336,13 @@ "dequal": "^2.0.3" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, "node_modules/ast-types": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", @@ -8635,6 +8671,77 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/boxen": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", @@ -8821,6 +8928,16 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -8863,6 +8980,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -9372,6 +9506,16 @@ "node": ">= 0.6" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -9388,6 +9532,13 @@ "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -9806,6 +9957,16 @@ "optional": true, "peer": true }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -9815,6 +9976,17 @@ "node": ">=6" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -9982,6 +10154,13 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/effect": { "version": "3.16.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.4.tgz", @@ -10037,6 +10216,16 @@ "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -10244,6 +10433,13 @@ "node": ">=8" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -10349,6 +10545,16 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -10409,6 +10615,80 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/ext-list": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", @@ -10706,6 +10986,42 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -10811,6 +11127,16 @@ "node": ">=0.4.x" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -10869,6 +11195,16 @@ "license": "0BSD", "peer": true }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -11602,6 +11938,23 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -11796,6 +12149,16 @@ "kind-of": "^6.0.2" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", @@ -15422,12 +15785,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-refs": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz", @@ -15461,6 +15844,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mhchemparser": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/mhchemparser/-/mhchemparser-4.2.1.tgz", @@ -16166,6 +16559,19 @@ "url": "https://github.com/sponsors/aidenybai" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -16636,12 +17042,38 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ohash": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -16799,6 +17231,16 @@ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", "license": "MIT" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -16856,6 +17298,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -17357,6 +17806,20 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -17423,6 +17886,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -17462,6 +17941,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -18797,6 +19319,74 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -18805,6 +19395,13 @@ "optional": true, "peer": true }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shadcn-ui": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/shadcn-ui/-/shadcn-ui-0.9.5.tgz", @@ -18860,6 +19457,82 @@ "suid": "bin/short-unique-id" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -19195,6 +19868,16 @@ "node": ">=8" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/streamx": { "version": "2.22.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", @@ -19886,6 +20569,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/token-types": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz", @@ -20075,6 +20768,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -20380,6 +21087,16 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", @@ -20561,6 +21278,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -21759,6 +22486,16 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index b47f340c5a..0a48c01385 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -118,6 +118,7 @@ "devDependencies": { "@biomejs/biome": "2.1.1", "@jest/types": "^30.0.1", + "@modelcontextprotocol/server-everything": "^0.6.2", "@playwright/test": "^1.52.0", "@swc/cli": "^0.5.2", "@swc/core": "^1.6.1", diff --git a/src/frontend/src/modals/addMcpServerModal/index.tsx b/src/frontend/src/modals/addMcpServerModal/index.tsx index c706755a57..17cb4af266 100644 --- a/src/frontend/src/modals/addMcpServerModal/index.tsx +++ b/src/frontend/src/modals/addMcpServerModal/index.tsx @@ -49,7 +49,7 @@ export default function AddMcpServerModal({ : useState(false); const [type, setType] = useState( - initialData ? (initialData.command ? "STDIO" : "SSE") : "JSON", + initialData ? (initialData.command ? "STDIO" : "HTTP") : "JSON", ); const [jsonValue, setJsonValue] = useState(""); const [error, setError] = useState( @@ -73,10 +73,10 @@ export default function AddMcpServerModal({ setStdioCommand(""); setStdioArgs([""]); setStdioEnv([]); - setSseName(""); - setSseUrl(""); - setSseEnv([]); - setSseHeaders([]); + setHttpName(""); + setHttpUrl(""); + setHttpEnv([]); + setHttpHeaders([]); }; // STDIO state @@ -87,25 +87,27 @@ export default function AddMcpServerModal({ ); const [stdioEnv, setStdioEnv] = useState(initialData?.env || []); - // SSE state - const [sseName, setSseName] = useState(initialData?.name || ""); - const [sseUrl, setSseUrl] = useState(initialData?.url || ""); - const [sseEnv, setSseEnv] = useState(initialData?.env || []); - const [sseHeaders, setSseHeaders] = useState(initialData?.headers || []); + // HTTP state + const [httpName, setHttpName] = useState(initialData?.name || ""); + const [httpUrl, setHttpUrl] = useState(initialData?.url || ""); + const [httpEnv, setHttpEnv] = useState(initialData?.env || []); + const [httpHeaders, setHttpHeaders] = useState( + initialData?.headers || [], + ); useEffect(() => { if (open) { - setType(initialData ? (initialData.command ? "STDIO" : "SSE") : "JSON"); + setType(initialData ? (initialData.command ? "STDIO" : "HTTP") : "JSON"); setError(null); setJsonValue(""); setStdioName(initialData?.name || ""); setStdioCommand(initialData?.command || ""); setStdioArgs(initialData?.args || [""]); setStdioEnv(initialData?.env || []); - setSseName(initialData?.name || ""); - setSseUrl(initialData?.url || ""); - setSseEnv(initialData?.env || []); - setSseHeaders(initialData?.headers || []); + setHttpName(initialData?.name || ""); + setHttpUrl(initialData?.url || ""); + setHttpEnv(initialData?.env || []); + setHttpHeaders(initialData?.headers || []); } }, [open]); @@ -159,12 +161,12 @@ export default function AddMcpServerModal({ } return; } - if (type === "SSE") { - if (!sseName.trim() || !sseUrl.trim()) { + if (type === "HTTP") { + if (!httpName.trim() || !httpUrl.trim()) { setError("Name and URL are required."); return; } - const name = parseString(sseName, [ + const name = parseString(httpName, [ "mcp_name_case", "no_blank", "lowercase", @@ -172,9 +174,9 @@ export default function AddMcpServerModal({ try { await modifyMCPServer({ name, - env: parseEnvList(sseEnv), - url: sseUrl, - headers: parseEnvList(sseHeaders), + env: parseEnvList(httpEnv), + url: httpUrl, + headers: parseEnvList(httpHeaders), }); if (!initialData) { await queryClient.setQueryData(["useGetMCPServers"], (old: any) => { @@ -183,10 +185,10 @@ export default function AddMcpServerModal({ } onSuccess?.(name); setOpen(false); - setSseName(""); - setSseUrl(""); - setSseEnv([]); - setSseHeaders([]); + setHttpName(""); + setHttpUrl(""); + setHttpEnv([]); + setHttpHeaders([]); setError(null); } catch (err: any) { setError(err?.message || "Failed to add MCP server."); @@ -285,11 +287,11 @@ export default function AddMcpServerModal({ STDIO - SSE + Streamable HTTP/SSE @@ -374,52 +376,53 @@ export default function AddMcpServerModal({ - +
setSseName(e.target.value)} + value={httpName} + onChange={(e) => setHttpName(e.target.value)} placeholder="Name" - data-testid="sse-name-input" + data-testid="http-name-input" disabled={isPending} />
setSseUrl(e.target.value)} - placeholder="SSE URL" - data-testid="sse-url-input" + value={httpUrl} + onChange={(e) => setHttpUrl(e.target.value)} + placeholder="Streamable HTTP/SSE URL" + data-testid="http-url-input" disabled={isPending} />
diff --git a/src/frontend/tests/extended/features/mcp-server.spec.ts b/src/frontend/tests/extended/features/mcp-server.spec.ts index 3e8f9bdd62..55f2cb445a 100644 --- a/src/frontend/tests/extended/features/mcp-server.spec.ts +++ b/src/frontend/tests/extended/features/mcp-server.spec.ts @@ -159,7 +159,7 @@ test( timeout: 3000, }); - await expect(page.getByTestId("sse-tab")).toBeDisabled({ + await expect(page.getByTestId("http-tab")).toBeDisabled({ timeout: 3000, }); @@ -242,7 +242,7 @@ test( const sidebarButton = page.getByTestId("sidebar-add-mcp-server-button"); const fallbackButton = page.getByTestId("add-mcp-server-button-sidebar"); - if (await sidebarButton.isVisible({ timeout: 3000 }).catch(() => false)) { + if (await sidebarButton.isVisible({ timeout: 5000 }).catch(() => false)) { await sidebarButton.click(); } else { await fallbackButton.click(); @@ -267,6 +267,8 @@ test( await page.getByTestId("add-mcp-server-button").click(); + await page.waitForTimeout(1000); + await page.getByTestId(`add-component-button-${testName}`).click(); await expect(page.getByTestId("dropdown_str_tool")).toBeVisible({ @@ -521,7 +523,7 @@ test( ); test( - "SSE MCP server fields should persist after saving and editing", + "HTTP/SSE MCP server fields should persist after saving and editing", { tag: ["@release", "@workspace", "@components"] }, async ({ page }) => { await awaitBootstrapTest(page); @@ -560,16 +562,16 @@ test( timeout: 30000, }); - // Go to SSE tab and fill all fields - await page.getByTestId("sse-tab").click(); - await page.waitForSelector('[data-testid="sse-name-input"]', { + // Go to HTTP tab and fill all fields + await page.getByTestId("http-tab").click(); + await page.waitForSelector('[data-testid="http-name-input"]', { state: "visible", timeout: 30000, }); // Test data with random suffix const randomSuffix = Math.floor(Math.random() * 90000) + 10000; // 5-digit random number - const testName = `test_sse_server_${randomSuffix}`; + const testName = `test_http_server_${randomSuffix}`; const testUrl = "https://api.example.com/mcp"; const testHeaderKey1 = "Authorization"; const testHeaderValue1 = "Bearer token123"; @@ -581,26 +583,26 @@ test( const testEnvValue2 = "3"; // Fill basic fields - await page.getByTestId("sse-name-input").fill(testName); - await page.getByTestId("sse-url-input").fill(testUrl); + await page.getByTestId("http-name-input").fill(testName); + await page.getByTestId("http-url-input").fill(testUrl); // Add first header - await page.getByTestId("sse-headers-key-0").fill(testHeaderKey1); - await page.getByTestId("sse-headers-value-0").fill(testHeaderValue1); + await page.getByTestId("http-headers-key-0").fill(testHeaderKey1); + await page.getByTestId("http-headers-value-0").fill(testHeaderValue1); // Add second header - await page.getByTestId("sse-headers-plus-btn-0").click(); - await page.getByTestId("sse-headers-key-1").fill(testHeaderKey2); - await page.getByTestId("sse-headers-value-1").fill(testHeaderValue2); + await page.getByTestId("http-headers-plus-btn-0").click(); + await page.getByTestId("http-headers-key-1").fill(testHeaderKey2); + await page.getByTestId("http-headers-value-1").fill(testHeaderValue2); // Add first environment variable - await page.getByTestId("sse-env-key-0").fill(testEnvKey1); - await page.getByTestId("sse-env-value-0").fill(testEnvValue1); + await page.getByTestId("http-env-key-0").fill(testEnvKey1); + await page.getByTestId("http-env-value-0").fill(testEnvValue1); // Add second environment variable - await page.getByTestId("sse-env-plus-btn-0").click(); - await page.getByTestId("sse-env-key-1").fill(testEnvKey2); - await page.getByTestId("sse-env-value-1").fill(testEnvValue2); + await page.getByTestId("http-env-plus-btn-0").click(); + await page.getByTestId("http-env-key-1").fill(testEnvKey2); + await page.getByTestId("http-env-value-1").fill(testEnvValue2); // Save the server await page.getByTestId("add-mcp-server-button").click(); @@ -641,32 +643,32 @@ test( }); // Verify all fields persisted correctly - expect(await page.getByTestId("sse-name-input").inputValue()).toBe( + expect(await page.getByTestId("http-name-input").inputValue()).toBe( testName, ); - expect(await page.getByTestId("sse-url-input").inputValue()).toBe(testUrl); - expect(await page.getByTestId("sse-headers-key-0").inputValue()).toBe( + expect(await page.getByTestId("http-url-input").inputValue()).toBe(testUrl); + expect(await page.getByTestId("http-headers-key-0").inputValue()).toBe( testHeaderKey1, ); - expect(await page.getByTestId("sse-headers-value-0").inputValue()).toBe( + expect(await page.getByTestId("http-headers-value-0").inputValue()).toBe( testHeaderValue1, ); - expect(await page.getByTestId("sse-headers-key-1").inputValue()).toBe( + expect(await page.getByTestId("http-headers-key-1").inputValue()).toBe( testHeaderKey2, ); - expect(await page.getByTestId("sse-headers-value-1").inputValue()).toBe( + expect(await page.getByTestId("http-headers-value-1").inputValue()).toBe( testHeaderValue2, ); - expect(await page.getByTestId("sse-env-key-0").inputValue()).toBe( + expect(await page.getByTestId("http-env-key-0").inputValue()).toBe( testEnvKey1, ); - expect(await page.getByTestId("sse-env-value-0").inputValue()).toBe( + expect(await page.getByTestId("http-env-value-0").inputValue()).toBe( testEnvValue1, ); - expect(await page.getByTestId("sse-env-key-1").inputValue()).toBe( + expect(await page.getByTestId("http-env-key-1").inputValue()).toBe( testEnvKey2, ); - expect(await page.getByTestId("sse-env-value-1").inputValue()).toBe( + expect(await page.getByTestId("http-env-value-1").inputValue()).toBe( testEnvValue2, ); @@ -838,7 +840,7 @@ test( timeout: 3000, }); - await expect(page.getByTestId("sse-tab")).toBeDisabled({ + await expect(page.getByTestId("http-tab")).toBeDisabled({ timeout: 3000, }); @@ -982,3 +984,214 @@ test( expect(fetchOptionCount2).toBeGreaterThan(0); }, ); + +test( + "Streamable HTTP MCP server with server-everything should load tools correctly", + { tag: ["@release", "@workspace", "@components"] }, + async ({ page }) => { + // Start the MCP server with proper health checking + const server = "https://mcp.deepwiki.com/mcp"; + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + await page.getByTestId("sidebar-search-input").click(); + await page.getByTestId("sidebar-search-input").fill("mcp tools"); + + await page.waitForSelector('[data-testid="agentsMCP Tools"]', { + timeout: 30000, + }); + + await page + .getByTestId("agentsMCP Tools") + .dragTo(page.locator('//*[@id="react-flow-id"]'), { + targetPosition: { x: 100, y: 100 }, + }); + + await adjustScreenView(page, { numberOfZoomOut: 3 }); + + try { + await page.getByText("Add MCP Server", { exact: true }).click({ + timeout: 5000, + }); + } catch (_error) { + await page.getByTestId("mcp-server-dropdown").click({ timeout: 3000 }); + await page.getByText("Add MCP Server", { exact: true }).click({ + timeout: 5000, + }); + } + + await page.waitForSelector('[data-testid="add-mcp-server-button"]', { + state: "visible", + timeout: 30000, + }); + + // Switch to HTTP tab for Streamable HTTP + await page.getByTestId("http-tab").click(); + + await page.waitForSelector('[data-testid="http-name-input"]', { + state: "visible", + timeout: 30000, + }); + + const randomSuffix = Math.floor(Math.random() * 90000) + 10000; + const testName = `test_streamable_http_${randomSuffix}`; + + // Fill in the server details + await page.getByTestId("http-name-input").fill(testName); + + // Use the HTTP endpoint URL + await page.getByTestId("http-url-input").fill(server); + + await page.getByTestId("add-mcp-server-button").click(); + + // Wait for tools to load with proper timeout + await page.waitForSelector( + '[data-testid="dropdown_str_tool"]:not([disabled])', + { + timeout: 10000, + state: "visible", + }, + ); + + await page.getByTestId("dropdown_str_tool").click(); + + // Check for tools from server + const toolOptions = page.locator('[data-testid*="-option"]'); + const toolCount = await toolOptions.count(); + + // server-everything should have multiple tools (at least 5+) + expect(toolCount).toBeGreaterThan(5); + + // Verify specific tools exist from server-everything + const readWikiStructureOption = page.getByTestId( + "read_wiki_structure-0-option", + ); + expect(await readWikiStructureOption.count()).toBeGreaterThan(0); + + // Select the option to verify it loads properly + await readWikiStructureOption.last().click(); + + // Wait for the tool input field to appear + await page.waitForSelector( + '[data-testid="popover-anchor-input-repoName"]', + { + state: "visible", + timeout: 10000, + }, + ); + + // Verify the input field is present + await expect( + page.getByTestId("popover-anchor-input-repoName"), + ).toBeVisible(); + }, +); + +test( + "SSE MCP server with deepwiki should load tools correctly", + { tag: ["@release", "@workspace", "@components"] }, + async ({ page }) => { + // Start the MCP server with proper health checking + const server = "https://mcp.deepwiki.com/sse"; + + await awaitBootstrapTest(page); + + await page.waitForSelector('[data-testid="blank-flow"]', { + timeout: 30000, + }); + await page.getByTestId("blank-flow").click(); + await page.getByTestId("sidebar-search-input").click(); + await page.getByTestId("sidebar-search-input").fill("mcp tools"); + + await page.waitForSelector('[data-testid="agentsMCP Tools"]', { + timeout: 30000, + }); + + await page + .getByTestId("agentsMCP Tools") + .dragTo(page.locator('//*[@id="react-flow-id"]'), { + targetPosition: { x: 100, y: 100 }, + }); + + await adjustScreenView(page, { numberOfZoomOut: 3 }); + + try { + await page.getByText("Add MCP Server", { exact: true }).click({ + timeout: 5000, + }); + } catch (_error) { + await page.getByTestId("mcp-server-dropdown").click({ timeout: 3000 }); + await page.getByText("Add MCP Server", { exact: true }).click({ + timeout: 5000, + }); + } + + await page.waitForSelector('[data-testid="add-mcp-server-button"]', { + state: "visible", + timeout: 30000, + }); + + // Switch to HTTP tab for SSE + await page.getByTestId("http-tab").click(); + + await page.waitForSelector('[data-testid="http-name-input"]', { + state: "visible", + timeout: 30000, + }); + + const randomSuffix = Math.floor(Math.random() * 90000) + 10000; + const testName = `test_sse_${randomSuffix}`; + + // Fill in the server details + await page.getByTestId("http-name-input").fill(testName); + + // Use the HTTP endpoint URL + await page.getByTestId("http-url-input").fill(server); + + await page.getByTestId("add-mcp-server-button").click(); + + // Wait for tools to load with proper timeout + await page.waitForSelector( + '[data-testid="dropdown_str_tool"]:not([disabled])', + { + timeout: 10000, + state: "visible", + }, + ); + + await page.getByTestId("dropdown_str_tool").click(); + + // Check for tools from wiki + const toolOptions = page.locator('[data-testid*="-option"]'); + const toolCount = await toolOptions.count(); + + // server-everything should have multiple tools (at least 5+) + expect(toolCount).toBeGreaterThan(5); + + // Verify specific tools exist from server-everything + const readWikiStructureOption = page.getByTestId( + "read_wiki_structure-0-option", + ); + expect(await readWikiStructureOption.count()).toBeGreaterThan(0); + + // Select the readWikiStructure to verify it loads properly + await readWikiStructureOption.last().click(); + + // Wait for the tool input field to appear + await page.waitForSelector( + '[data-testid="popover-anchor-input-repoName"]', + { + state: "visible", + timeout: 10000, + }, + ); + + // Verify the input field is present + await expect( + page.getByTestId("popover-anchor-input-repoName"), + ).toBeVisible(); + }, +); diff --git a/src/lfx/src/lfx/base/mcp/util.py b/src/lfx/src/lfx/base/mcp/util.py index 6adad58714..55e87433b4 100644 --- a/src/lfx/src/lfx/base/mcp/util.py +++ b/src/lfx/src/lfx/base/mcp/util.py @@ -28,8 +28,12 @@ HTTP_ERROR_STATUS_CODE = httpx_codes.BAD_REQUEST # HTTP status code for client # HTTP status codes used in validation HTTP_NOT_FOUND = 404 +HTTP_METHOD_NOT_ALLOWED = 405 +HTTP_NOT_ACCEPTABLE = 406 HTTP_BAD_REQUEST = 400 HTTP_INTERNAL_SERVER_ERROR = 500 +HTTP_UNAUTHORIZED = 401 +HTTP_FORBIDDEN = 403 # MCP Session Manager constants settings = get_settings_service().settings @@ -378,8 +382,8 @@ def _validate_node_installation(command: str) -> str: async def _validate_connection_params(mode: str, command: str | None = None, url: str | None = None) -> None: """Validate connection parameters based on mode.""" - if mode not in ["Stdio", "SSE"]: - msg = f"Invalid mode: {mode}. Must be either 'Stdio' or 'SSE'" + if mode not in ["Stdio", "Streamable_HTTP", "SSE"]: + msg = f"Invalid mode: {mode}. Must be either 'Stdio', 'Streamable_HTTP', or 'SSE'" raise ValueError(msg) if mode == "Stdio" and not command: @@ -387,8 +391,8 @@ async def _validate_connection_params(mode: str, command: str | None = None, url raise ValueError(msg) if mode == "Stdio" and command: _validate_node_installation(command) - if mode == "SSE" and not url: - msg = "URL is required for SSE mode" + if mode in ["Streamable_HTTP", "SSE"] and not url: + msg = f"URL is required for {mode} mode" raise ValueError(msg) @@ -400,6 +404,7 @@ class MCPSessionManager: 2. Maximum session limits per server to prevent resource exhaustion 3. Idle timeout for automatic session cleanup 4. Periodic cleanup of stale sessions + 5. Transport preference caching to avoid retrying failed transports """ def __init__(self): @@ -410,6 +415,9 @@ class MCPSessionManager: self._context_to_session: dict[str, tuple[str, str]] = {} # Reference count for each active (server_key, session_id) self._session_refcount: dict[tuple[str, str], int] = {} + # Cache which transport works for each server to avoid retrying failed transports + # server_key -> "streamable_http" | "sse" + self._transport_preference: dict[str, str] = {} self._cleanup_task = None self._start_cleanup_task() @@ -467,15 +475,16 @@ class MCPSessionManager: env_str = str(sorted((connection_params.env or {}).items())) key_input = f"{command_str}|{env_str}" return f"stdio_{hash(key_input)}" - elif transport_type == "sse" and (isinstance(connection_params, dict) and "url" in connection_params): + elif transport_type == "streamable_http" and ( + isinstance(connection_params, dict) and "url" in connection_params + ): # Include URL and headers for uniqueness url = connection_params["url"] headers = str(sorted((connection_params.get("headers", {})).items())) key_input = f"{url}|{headers}" - return f"sse_{hash(key_input)}" + return f"streamable_http_{hash(key_input)}" # Fallback to a generic key - # TODO: add option for streamable HTTP in future. return f"{transport_type}_{hash(str(connection_params))}" async def _validate_session_connectivity(self, session) -> bool: @@ -525,7 +534,7 @@ class MCPSessionManager: """Get or create a session with improved reuse strategy. The key insight is that we should reuse sessions based on the server - identity (command + args for stdio, URL for SSE) rather than the context_id. + identity (command + args for stdio, URL for Streamable HTTP) rather than the context_id. This prevents creating a new subprocess for each unique context. """ server_key = self._get_server_key(connection_params, transport_type) @@ -578,17 +587,24 @@ class MCPSessionManager: if transport_type == "stdio": session, task = await self._create_stdio_session(session_id, connection_params) - elif transport_type == "sse": - session, task = await self._create_sse_session(session_id, connection_params) + actual_transport = "stdio" + elif transport_type == "streamable_http": + # Pass the cached transport preference if available + preferred_transport = self._transport_preference.get(server_key) + session, task, actual_transport = await self._create_streamable_http_session( + session_id, connection_params, preferred_transport + ) + # Cache the transport that worked for future connections + self._transport_preference[server_key] = actual_transport else: msg = f"Unknown transport type: {transport_type}" raise ValueError(msg) - # Store session info + # Store session info with the actual transport used sessions[session_id] = { "session": session, "task": task, - "type": transport_type, + "type": actual_transport, "last_used": asyncio.get_event_loop().time(), } @@ -634,9 +650,9 @@ class MCPSessionManager: self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - # Wait for session to be ready + # Wait for session to be ready (use longer timeout for remote connections) try: - session = await asyncio.wait_for(session_future, timeout=10.0) + session = await asyncio.wait_for(session_future, timeout=30.0) except asyncio.TimeoutError as timeout_err: # Clean up the failed task if not task.done(): @@ -652,50 +668,136 @@ class MCPSessionManager: return session, task - async def _create_sse_session(self, session_id: str, connection_params): - """Create a new SSE session as a background task to avoid context issues.""" + async def _create_streamable_http_session( + self, session_id: str, connection_params, preferred_transport: str | None = None + ): + """Create a new Streamable HTTP session with SSE fallback as a background task to avoid context issues. + + Args: + session_id: Unique identifier for this session + connection_params: Connection parameters including URL, headers, timeouts + preferred_transport: If set to "sse", skip Streamable HTTP and go directly to SSE + + Returns: + tuple: (session, task, transport_used) where transport_used is "streamable_http" or "sse" + """ import asyncio from mcp.client.sse import sse_client + from mcp.client.streamable_http import streamablehttp_client # Create a future to get the session session_future: asyncio.Future[ClientSession] = asyncio.Future() + # Track which transport succeeded + used_transport: list[str] = [] async def session_task(): """Background task that keeps the session alive.""" - try: - async with sse_client( - connection_params["url"], - connection_params["headers"], - connection_params["timeout_seconds"], - connection_params["sse_read_timeout_seconds"], - ) as (read, write): - session = ClientSession(read, write) - async with session: - await session.initialize() - # Signal that session is ready - session_future.set_result(session) + streamable_error = None - # Keep the session alive until cancelled - import anyio + # Skip Streamable HTTP if we know SSE works for this server + if preferred_transport != "sse": + # Try Streamable HTTP first with a quick timeout + try: + await logger.adebug(f"Attempting Streamable HTTP connection for session {session_id}") + # Use a shorter timeout for the initial connection attempt (2 seconds) + async with streamablehttp_client( + url=connection_params["url"], + headers=connection_params["headers"], + timeout=connection_params["timeout_seconds"], + ) as (read, write, _): + session = ClientSession(read, write) + async with session: + # Initialize with a timeout to fail fast + await asyncio.wait_for(session.initialize(), timeout=2.0) + used_transport.append("streamable_http") + await logger.ainfo(f"Session {session_id} connected via Streamable HTTP") + # Signal that session is ready + session_future.set_result(session) - event = anyio.Event() - try: - await event.wait() - except asyncio.CancelledError: - await logger.ainfo(f"Session {session_id} is shutting down") - except Exception as e: # noqa: BLE001 - if not session_future.done(): - session_future.set_exception(e) + # Keep the session alive until cancelled + import anyio + + event = anyio.Event() + try: + await event.wait() + except asyncio.CancelledError: + await logger.ainfo(f"Session {session_id} (Streamable HTTP) is shutting down") + except (asyncio.TimeoutError, Exception) as e: # noqa: BLE001 + # If Streamable HTTP fails or times out, try SSE as fallback immediately + streamable_error = e + error_type = "timed out" if isinstance(e, asyncio.TimeoutError) else "failed" + await logger.awarning( + f"Streamable HTTP {error_type} for session {session_id}: {e}. Falling back to SSE..." + ) + else: + await logger.adebug(f"Skipping Streamable HTTP for session {session_id}, using cached SSE preference") + + # Try SSE if Streamable HTTP failed or if SSE is preferred + if streamable_error is not None or preferred_transport == "sse": + try: + await logger.adebug(f"Attempting SSE connection for session {session_id}") + # Extract SSE read timeout from connection params, default to 30s if not present + sse_read_timeout = connection_params.get("sse_read_timeout_seconds", 30) + + async with sse_client( + connection_params["url"], + connection_params["headers"], + connection_params["timeout_seconds"], + sse_read_timeout, + ) as (read, write): + session = ClientSession(read, write) + async with session: + await session.initialize() + used_transport.append("sse") + fallback_msg = " (fallback)" if streamable_error else " (preferred)" + await logger.ainfo(f"Session {session_id} connected via SSE{fallback_msg}") + # Signal that session is ready + if not session_future.done(): + session_future.set_result(session) + + # Keep the session alive until cancelled + import anyio + + event = anyio.Event() + try: + await event.wait() + except asyncio.CancelledError: + await logger.ainfo(f"Session {session_id} (SSE) is shutting down") + except Exception as sse_error: # noqa: BLE001 + # Both transports failed (or just SSE if it was preferred) + if streamable_error: + await logger.aerror( + f"Both Streamable HTTP and SSE failed for session {session_id}. " + f"Streamable HTTP error: {streamable_error}. SSE error: {sse_error}" + ) + if not session_future.done(): + session_future.set_exception( + ValueError( + f"Failed to connect via Streamable HTTP ({streamable_error}) or SSE ({sse_error})" + ) + ) + else: + await logger.aerror(f"SSE connection failed for session {session_id}: {sse_error}") + if not session_future.done(): + session_future.set_exception(ValueError(f"Failed to connect via SSE: {sse_error}")) # Start the background task task = asyncio.create_task(session_task()) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) - # Wait for session to be ready + # Wait for session to be ready (use longer timeout for remote connections) try: - session = await asyncio.wait_for(session_future, timeout=10.0) + session = await asyncio.wait_for(session_future, timeout=30.0) + # Log which transport was used + if used_transport: + transport_used = used_transport[0] + await logger.ainfo(f"Session {session_id} successfully established using {transport_used}") + return session, task, transport_used + # This shouldn't happen, but handle it just in case + msg = f"Session {session_id} established but transport not recorded" + raise ValueError(msg) except asyncio.TimeoutError as timeout_err: # Clean up the failed task if not task.done(): @@ -705,12 +807,10 @@ class MCPSessionManager: with contextlib.suppress(asyncio.CancelledError): await task self._background_tasks.discard(task) - msg = f"Timeout waiting for SSE session {session_id} to initialize" + msg = f"Timeout waiting for Streamable HTTP/SSE session {session_id} to initialize" await logger.aerror(msg) raise ValueError(msg) from timeout_err - return session, task - async def _cleanup_session_by_id(self, server_key: str, session_id: str): """Clean up a specific session by server key and session ID.""" if server_key not in self.sessions_by_server: @@ -1056,7 +1156,7 @@ class MCPStdioClient: await self.disconnect() -class MCPSseClient: +class MCPStreamableHttpClient: def __init__(self, component_cache=None): self.session: ClientSession | None = None self._connection_params = None @@ -1080,67 +1180,15 @@ class MCPSseClient: self._component_cache.set("mcp_session_manager", session_manager) return session_manager - async def validate_url(self, url: str | None, headers: dict[str, str] | None = None) -> tuple[bool, str]: - """Validate the SSE URL before attempting connection.""" + async def validate_url(self, url: str | None) -> tuple[bool, str]: + """Validate the Streamable HTTP URL before attempting connection.""" try: parsed = urlparse(url) if not parsed.scheme or not parsed.netloc: return False, "Invalid URL format. Must include scheme (http/https) and host." - - async with httpx.AsyncClient() as client: - try: - # For SSE endpoints, try a GET request with short timeout - # Many SSE servers don't support HEAD requests and return 404 - response = await client.get( - url, timeout=2.0, headers={"Accept": "text/event-stream", **(headers or {})} - ) - - # For SSE, we expect the server to either: - # 1. Start streaming (200) - # 2. Return 404 if HEAD/GET without proper SSE handshake is not supported - # 3. Return other status codes that we should handle gracefully - - # Don't fail on 404 since many SSE endpoints return this for non-SSE requests - if response.status_code == HTTP_NOT_FOUND: - # This is likely an SSE endpoint that doesn't support regular GET - # Let the actual SSE connection attempt handle this - return True, "" - - # Fail on client errors except 404, but allow server errors and redirects - if ( - HTTP_BAD_REQUEST <= response.status_code < HTTP_INTERNAL_SERVER_ERROR - and response.status_code != HTTP_NOT_FOUND - ): - return False, f"Server returned client error status: {response.status_code}" - - except httpx.TimeoutException: - # Timeout on a short request might indicate the server is trying to stream - # This is actually expected behavior for SSE endpoints - return True, "" - except httpx.NetworkError: - return False, "Network error. Could not reach the server." - else: - return True, "" - - except (httpx.HTTPError, ValueError, OSError) as e: + except (ValueError, OSError) as e: return False, f"URL validation error: {e!s}" - - async def pre_check_redirect(self, url: str | None, headers: dict[str, str] | None = None) -> str | None: - """Check for redirects and return the final URL.""" - if url is None: - return url - try: - async with httpx.AsyncClient(follow_redirects=False) as client: - # Use GET with SSE headers instead of HEAD since many SSE servers don't support HEAD - response = await client.get( - url, timeout=2.0, headers={"Accept": "text/event-stream", **(headers or {})} - ) - if response.status_code == httpx.codes.TEMPORARY_REDIRECT: - return response.headers.get("Location", url) - # Don't treat 404 as an error here - let the main connection handle it - except (httpx.RequestError, httpx.HTTPError) as e: - await logger.awarning(f"Error checking redirects: {e}") - return url + return True, "" async def _connect_to_server( self, @@ -1149,27 +1197,31 @@ class MCPSseClient: timeout_seconds: int = 30, sse_read_timeout_seconds: int = 30, ) -> list[StructuredTool]: - """Connect to MCP server using SSE transport (SDK style).""" + """Connect to MCP server using Streamable HTTP transport with SSE fallback (SDK style).""" # Validate and sanitize headers early validated_headers = _process_headers(headers) if url is None: - msg = "URL is required for SSE mode" - raise ValueError(msg) - is_valid, error_msg = await self.validate_url(url, validated_headers) - if not is_valid: - msg = f"Invalid SSE URL ({url}): {error_msg}" + msg = "URL is required for StreamableHTTP or SSE mode" raise ValueError(msg) - url = await self.pre_check_redirect(url, validated_headers) - - # Store connection parameters for later use in run_tool - self._connection_params = { - "url": url, - "headers": validated_headers, - "timeout_seconds": timeout_seconds, - "sse_read_timeout_seconds": sse_read_timeout_seconds, - } + # Only validate URL if we don't have a cached session + # This avoids expensive HTTP validation calls when reusing sessions + if not self._connected or not self._connection_params: + is_valid, error_msg = await self.validate_url(url) + if not is_valid: + msg = f"Invalid Streamable HTTP or SSE URL ({url}): {error_msg}" + raise ValueError(msg) + # Store connection parameters for later use in run_tool + # Include SSE read timeout for fallback + self._connection_params = { + "url": url, + "headers": validated_headers, + "timeout_seconds": timeout_seconds, + "sse_read_timeout_seconds": sse_read_timeout_seconds, + } + elif headers: + self._connection_params["headers"] = validated_headers # If no session context is set, create a default one if not self._session_context: @@ -1177,18 +1229,21 @@ class MCPSseClient: import uuid param_hash = uuid.uuid4().hex[:8] - self._session_context = f"default_sse_{param_hash}" + self._session_context = f"default_http_{param_hash}" - # Get or create a persistent session + # Get or create a persistent session (will try Streamable HTTP, then SSE fallback) session = await self._get_or_create_session() response = await session.list_tools() self._connected = True return response.tools - async def connect_to_server(self, url: str, headers: dict[str, str] | None = None) -> list[StructuredTool]: - """Connect to MCP server using SSE transport (SDK style).""" + async def connect_to_server( + self, url: str, headers: dict[str, str] | None = None, sse_read_timeout_seconds: int = 30 + ) -> list[StructuredTool]: + """Connect to MCP server using Streamable HTTP with SSE fallback transport (SDK style).""" return await asyncio.wait_for( - self._connect_to_server(url, headers), timeout=get_settings_service().settings.mcp_server_timeout + self._connect_to_server(url, headers, sse_read_timeout_seconds=sse_read_timeout_seconds), + timeout=get_settings_service().settings.mcp_server_timeout, ) def set_session_context(self, context_id: str): @@ -1204,12 +1259,14 @@ class MCPSseClient: # Use cached session manager to get/create persistent session session_manager = self._get_session_manager() # Cache session so we can access server-assigned session_id later for DELETE - self.session = await session_manager.get_session(self._session_context, self._connection_params, "sse") + self.session = await session_manager.get_session( + self._session_context, self._connection_params, "streamable_http" + ) return self.session async def _terminate_remote_session(self) -> None: """Attempt to explicitly terminate the remote MCP session via HTTP DELETE (best-effort).""" - # Only relevant for SSE transport + # Only relevant for Streamable HTTP or SSE transport if not self._connection_params or "url" not in self._connection_params: return @@ -1255,7 +1312,7 @@ class MCPSseClient: import uuid param_hash = uuid.uuid4().hex[:8] - self._session_context = f"default_sse_{param_hash}" + self._session_context = f"default_http_{param_hash}" max_retries = 2 last_error_type = None @@ -1326,7 +1383,7 @@ class MCPSseClient: await logger.aerror(msg) # Clean up failed session from cache if self._session_context and self._component_cache: - cache_key = f"mcp_session_sse_{self._session_context}" + cache_key = f"mcp_session_http_{self._session_context}" self._component_cache.delete(cache_key) self._connected = False raise ValueError(msg) from e @@ -1364,11 +1421,17 @@ class MCPSseClient: await self.disconnect() +# Backward compatibility: MCPSseClient is now an alias for MCPStreamableHttpClient +# The new client supports both Streamable HTTP and SSE with automatic fallback +MCPSseClient = MCPStreamableHttpClient + + async def update_tools( server_name: str, server_config: dict, mcp_stdio_client: MCPStdioClient | None = None, - mcp_sse_client: MCPSseClient | None = None, + mcp_streamable_http_client: MCPStreamableHttpClient | None = None, + mcp_sse_client: MCPStreamableHttpClient | None = None, # Backward compatibility ) -> tuple[str, list[StructuredTool], dict[str, StructuredTool]]: """Fetch server config and update available tools.""" if server_config is None: @@ -1377,11 +1440,17 @@ async def update_tools( return "", [], {} if mcp_stdio_client is None: mcp_stdio_client = MCPStdioClient() - if mcp_sse_client is None: - mcp_sse_client = MCPSseClient() + + # Backward compatibility: accept mcp_sse_client parameter + if mcp_streamable_http_client is None: + mcp_streamable_http_client = mcp_sse_client if mcp_sse_client is not None else MCPStreamableHttpClient() # Fetch server config from backend - mode = "Stdio" if "command" in server_config else "SSE" if "url" in server_config else "" + # Determine mode from config, defaulting to Streamable_HTTP if URL present + mode = server_config.get("mode", "") + if not mode: + mode = "Stdio" if "command" in server_config else "Streamable_HTTP" if "url" in server_config else "" + command = server_config.get("command", "") url = server_config.get("url", "") tools = [] @@ -1394,7 +1463,7 @@ async def update_tools( raise # Determine connection type and parameters - client: MCPStdioClient | MCPSseClient | None = None + client: MCPStdioClient | MCPStreamableHttpClient | None = None if mode == "Stdio": # Stdio connection args = server_config.get("args", []) @@ -1402,10 +1471,10 @@ async def update_tools( full_command = " ".join([command, *args]) tools = await mcp_stdio_client.connect_to_server(full_command, env) client = mcp_stdio_client - elif mode == "SSE": - # SSE connection - tools = await mcp_sse_client.connect_to_server(url, headers=headers) - client = mcp_sse_client + elif mode in ["Streamable_HTTP", "SSE"]: + # Streamable HTTP connection with SSE fallback + tools = await mcp_streamable_http_client.connect_to_server(url, headers=headers) + client = mcp_streamable_http_client else: logger.error(f"Invalid MCP server mode for '{server_name}': {mode}") return "", [], {} diff --git a/src/lfx/src/lfx/components/agents/mcp_component.py b/src/lfx/src/lfx/components/agents/mcp_component.py index 1b1192c7c1..3cc1c58ec5 100644 --- a/src/lfx/src/lfx/components/agents/mcp_component.py +++ b/src/lfx/src/lfx/components/agents/mcp_component.py @@ -7,7 +7,12 @@ from typing import Any from langchain_core.tools import StructuredTool # noqa: TC002 from lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set -from lfx.base.mcp.util import MCPSseClient, MCPStdioClient, create_input_schema_from_json_schema, update_tools +from lfx.base.mcp.util import ( + MCPStdioClient, + MCPStreamableHttpClient, + create_input_schema_from_json_schema, + update_tools, +) from lfx.custom.custom_component.component_with_cache import ComponentWithCache from lfx.inputs.inputs import InputTypes # noqa: TC001 from lfx.io import BoolInput, DropdownInput, McpInput, MessageTextInput, Output @@ -32,7 +37,9 @@ class MCPToolsComponent(ComponentWithCache): # Initialize clients with access to the component cache self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache) - self.sse_client: MCPSseClient = MCPSseClient(component_cache=self._shared_component_cache) + self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient( + component_cache=self._shared_component_cache + ) def _ensure_cache_structure(self): """Ensure the cache has the required structure.""" @@ -207,7 +214,7 @@ class MCPToolsComponent(ComponentWithCache): server_name=server_name, server_config=server_config, mcp_stdio_client=self.stdio_client, - mcp_sse_client=self.sse_client, + mcp_streamable_http_client=self.streamable_http_client, ) self.tool_names = [tool.name for tool in tool_list if hasattr(tool, "name")] @@ -496,7 +503,7 @@ class MCPToolsComponent(ComponentWithCache): session_context = self._get_session_context() if session_context: self.stdio_client.set_session_context(session_context) - self.sse_client.set_session_context(session_context) + self.streamable_http_client.set_session_context(session_context) exec_tool = self._tool_cache[self.tool] tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool]