Skip to content

feat(mcp-server): introduce stdio mcp server - #615

Closed
mainred wants to merge 17 commits into
masterfrom
stdio-mcp-server
Closed

mainred wants to merge 17 commits into
masterfrom
stdio-mcp-server

Conversation

@mainred

@mainred mainred commented Jul 9, 2025

Copy link
Copy Markdown
Collaborator

No description provided.

@coderabbitai

coderabbitai Bot commented Jul 9, 2025 •

Copy link
Copy Markdown
Contributor

Walkthrough

Introduces transport-agnostic MCP tool and toolset abstractions, adds stdio transport and StdioMCPTool/Toolset, refactors RemoteMCPTool/Toolset to share BaseMCPTool/BaseMCPToolset, adds a config-driven factory, updates tests, and expands docs to cover SSE and stdio configurations and examples.

Changes

Cohort / File(s) Summary
Docs: Multi-transport MCP and config format
docs/data-sources/remote-mcp-servers.md
Expands docs to describe both SSE and stdio transports, introduces a nested config format with config.type, documents transport-specific fields and security notes, shows updated examples (CLI, YAML/helm), and adds a local stdio example.
Toolset factory wiring
holmes/plugins/toolsets/__init__.py
Replaces direct RemoteMCPToolset instantiation with factory get_mcp_toolset_from_config(config, name) to choose toolset by config.
MCP toolset refactor + stdio support
holmes/plugins/toolsets/mcp/toolset_mcp.py
Adds BaseMCPTool/BaseMCPToolset abstractions; refactors RemoteMCPTool/RemoteMCPToolset to inherit from base classes; adds StdioMCPTool/StdioMCPToolset; centralizes invoke/parsing/async hooks; implements transport-specific discovery/invocation (sse_client, stdio_client); exposes headers, url, and stdio params; adds get_mcp_toolset_from_config with backward-compat handling and example-config helpers; updates logging and connection-info messages.
Tests updated for new API and factory
tests/test_mcp_toolset.py
Adapts tests to config-driven API and factory: validates SSE and stdio creation, header exposure, SSE URL normalization/backward-compat, empty-config validation, and stdio-notfound handling; imports updated to use StdioMCPToolset and get_mcp_toolset_from_config.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Loader as Holmes (loader)
  participant Factory as get_mcp_toolset_from_config
  participant Toolset as BaseMCPToolset
  participant Client as Client (sse_client / stdio_client)
  participant Server as MCP Server

  Loader->>Factory: get_mcp_toolset_from_config(config, name)
  Factory-->>Loader: RemoteMCPToolset or StdioMCPToolset

  Loader->>Toolset: init_server_tools()
  Toolset->>Client: connect/discover tools
  Client->>Server: list_tools()
  Server-->>Client: tools[]
  Client-->>Toolset: tools[]

  Loader->>Toolset: invoke(tool_id, params)
  Toolset->>Client: call_tool(tool_id, params)
  Client->>Server: execute
  Server-->>Client: result
  Client-->>Toolset: StructuredToolResult
  Toolset-->>Loader: StructuredToolResult
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Potential hotspots to review:

  • BaseMCPTool/_invoke_async and integration with existing sync paths.
  • StdioMCPToolset process startup/env handling and error paths (stdio-notfound).
  • get_mcp_toolset_from_config backward-compat normalization (url -> config.url and SSE /sse appending).
  • Tests that mock clients (sse_client vs stdio_client) and header propagation.

Possibly related PRs

Suggested reviewers

  • RoiGlinik
  • arikalon1

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Description check ❓ Inconclusive No pull request description was provided by the author, making it impossible to assess relevance to the changeset. Add a description explaining the motivation, scope, and key changes (e.g., new StdioMCPToolset, config-driven API, backward compatibility).
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main objective of the changeset: introducing stdio MCP server support alongside existing SSE transport.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch stdio-mcp-server

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 74b0d35 and fdbad4d.

📒 Files selected for processing (1)
  • holmes/plugins/toolsets/__init__.py (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • holmes/plugins/toolsets/init.py
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Pre-commit checks
  • GitHub Check: llm_evals
  • GitHub Check: build

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mainred
mainred force-pushed the stdio-mcp-server branch 2 times, most recently from c4ac0ec to 29bfb2a Compare July 10, 2025 10:03
@mainred mainred closed this Jul 18, 2025
@mainred mainred reopened this Jul 30, 2025
@mainred
mainred marked this pull request as ready for review September 19, 2025 10:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)

268-290: Add type validation for stdio transport configuration.

The function should validate that required fields are present for stdio transport before creating the toolset.

     if mcp_type == "sse":
         return RemoteMCPToolset(**config, name=name)
     if mcp_type == "stdio":
+        # Validate required fields for stdio transport
+        if not mcp_config.get("command") and not config.get("command"):
+            raise ValueError("stdio transport requires 'command' field in configuration")
         return StdioMCPToolset(**config, name=name)
🧹 Nitpick comments (10)
docs/data-sources/remote-mcp-servers.md (2)

22-22: Fix markdown heading style inconsistency.

The heading uses setext style (underlines) instead of atx style (hashes) which is inconsistent with the rest of the document.

-Transport selection and supported fields
---------------------------------------
+### Transport selection and supported fields

54-54: Fix markdown heading style inconsistency.

The heading uses setext style instead of atx style.

-Security notes
---------------
+### Security notes
holmes/plugins/toolsets/mcp/toolset_mcp.py (4)

1-290: Code formatting required.

The pipeline indicates that ruff-format needs to be run on this file to fix formatting issues.

Run the following command to fix formatting:

#!/bin/bash
# Format the file using ruff
ruff format holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Remove unused parameter user_approved.

The user_approved parameter is never used in this method implementation.

-    def _invoke(
-        self, params: Dict, user_approved: bool = False
-    ) -> StructuredToolResult:
+    def _invoke(self, params: Dict) -> StructuredToolResult:

Note: This would need to be coordinated with the base Tool class interface if it expects this parameter.


95-95: Use explicit string conversion flags for better readability.

Replace string concatenation with f-string conversion flags.

-        return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+        return f"Call mcp server {self.url} tool {self.name} with params {params!s}"
-        return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+        return f"Call stdio mcp server tool {self.name} with params {params!s}"

Also applies to: 136-136


150-164: Improve exception handling specificity.

The code catches all exceptions broadly and converts them to strings using e.args, which may not always provide useful error messages.

     def init_server_tools(self, config: dict[str, Any]) -> Tuple[bool, str]:
         try:
             tools_result = asyncio.run(self._get_server_tools())
             self.tools = self._create_tools(tools_result.tools)

             if not self.tools:
                 logging.warning(
                     f"{self._get_server_type()} mcp server {self.name} loaded 0 tools."
                 )
             return (True, "")
-        except Exception as e:
+        except (ConnectionError, TimeoutError) as e:
+            return (
+                False,
+                f"Failed to connect to {self._get_server_type()} mcp server {self.name} {self._get_connection_info()}: {str(e)}",
+            )
+        except Exception as e:
             return (
                 False,
-                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {str(e.args)}",
+                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()}: {str(e)}",
             )
tests/test_mcp_toolset.py (4)

1-208: Code formatting required.

The pipeline indicates that ruff-format needs to be run on this file.

Run the following command to fix formatting:

#!/bin/bash
# Format the test file using ruff
ruff format tests/test_mcp_toolset.py

57-57: Use Pythonic false check instead of explicit comparison.

-    assert result[0] == False
+    assert not result[0]

84-86: Fix inconsistent formatting of monkeypatch.setattr call.

-    monkeypatch.setattr(mcp_toolset, "_get_server_tools",
-                        mock_get_server_tools)
+    monkeypatch.setattr(mcp_toolset, "_get_server_tools", mock_get_server_tools)

90-99: Remove unused monkeypatch parameter.

The monkeypatch parameter is not used in this test function.

-def test_mcpserver_headers(monkeypatch):
+def test_mcpserver_headers():
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c6fe1b2 and 31fa18a.

📒 Files selected for processing (4)
  • docs/data-sources/remote-mcp-servers.md (3 hunks)
  • holmes/plugins/toolsets/__init__.py (2 hunks)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (4 hunks)
  • tests/test_mcp_toolset.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
docs/**/*.md

📄 CodeRabbit inference engine (CLAUDE.md)

In MkDocs content, always add a blank line between a header/bold text and a list to render lists properly

Files:

  • docs/data-sources/remote-mcp-servers.md
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • holmes/plugins/toolsets/__init__.py
  • tests/test_mcp_toolset.py
  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/__init__.py
  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/__init__.py
  • holmes/plugins/toolsets/mcp/toolset_mcp.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pytest markers that are declared in pyproject.toml; never introduce undeclared markers/tags

Files:

  • tests/test_mcp_toolset.py
tests/**

📄 CodeRabbit inference engine (CLAUDE.md)

Test layout should mirror the source structure under tests/

Files:

  • tests/test_mcp_toolset.py
🧠 Learnings (3)
📚 Learning: 2025-09-15T07:09:16.052Z
Learnt from: CR
PR: robusta-dev/holmesgpt#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-15T07:09:16.052Z
Learning: Applies to {holmes/plugins/toolsets/**/*.yaml,tests/llm/**/toolsets.yaml} : Valid top-level fields for toolset YAMLs are limited to: enabled, name, description, additional_instructions, prerequisites, tools, docs_url, icon_url, installation_instructions, config, url (MCP only)

Applied to files:

  • docs/data-sources/remote-mcp-servers.md
📚 Learning: 2025-08-05T00:42:23.792Z
Learnt from: vishiy
PR: robusta-dev/holmesgpt#782
File: config.example.yaml:31-49
Timestamp: 2025-08-05T00:42:23.792Z
Learning: In robusta-dev/holmesgpt config.example.yaml, the azuremonitorlogs toolset configuration shows "enabled: true" as an example of how to enable the toolset, not as a default setting. The toolset is disabled by default and requires explicit enablement in user configurations.

Applied to files:

  • docs/data-sources/remote-mcp-servers.md
📚 Learning: 2025-09-15T07:09:16.052Z
Learnt from: CR
PR: robusta-dev/holmesgpt#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-15T07:09:16.052Z
Learning: Applies to holmes/plugins/toolsets/** : Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Applied to files:

  • docs/data-sources/remote-mcp-servers.md
🧬 Code graph analysis (3)
holmes/plugins/toolsets/__init__.py (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
  • get_mcp_toolset_from_config (268-289)
tests/test_mcp_toolset.py (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (7)
  • RemoteMCPToolset (183-223)
  • RemoteMCPTool (59-95)
  • StdioMCPToolset (226-265)
  • get_mcp_toolset_from_config (268-289)
  • init_server_tools (150-164)
  • headers (200-201)
  • url (204-205)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (11)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • ToolParameter (154-159)
  • StructuredToolResultStatus (51-75)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • get_parameterized_one_liner (352-353)
  • get_parameterized_one_liner (379-386)
  • model_post_init (181-215)
🪛 markdownlint-cli2 (0.17.2)
docs/data-sources/remote-mcp-servers.md

22-22: Heading style
Expected: atx; Actual: setext

(MD003, heading-style)


54-54: Heading style
Expected: atx; Actual: setext

(MD003, heading-style)

🪛 Ruff (0.12.2)
tests/test_mcp_toolset.py

57-57: Avoid equality comparisons to False; use not result[0]: for false checks

Replace with not result[0]

(E712)


90-90: Unused function argument: monkeypatch

(ARG001)

holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


95-95: Use explicit conversion flag

Replace with conversion flag

(RUF010)


136-136: Use explicit conversion flag

Replace with conversion flag

(RUF010)


150-150: Unused method argument: config

(ARG002)


159-159: Consider moving this statement to an else block

(TRY300)


160-160: Do not catch blind exception: Exception

(BLE001)


163-163: Use explicit conversion flag

Replace with conversion flag

(RUF010)


270-270: Avoid specifying long messages outside the exception class

(TRY003)


284-286: Avoid specifying long messages outside the exception class

(TRY003)

🪛 GitHub Actions: Build and test HolmesGPT
tests/test_mcp_toolset.py

[error] 57-57: Ruff E712: Avoid equality comparisons to False; use if not result[0]: for false checks.


[error] 1-1: Ruff-format reformatted tests/test_mcp_toolset.py.

holmes/plugins/toolsets/mcp/toolset_mcp.py

[error] 1-1: Ruff-format reformatted holmes/plugins/toolsets/mcp/toolset_mcp.py.


[error] 183-183: Mypy: Name 'url' already defined on line 183 [no-redef].


[error] 232-232: Mypy: Argument 1 to 'dict' has incompatible type 'Any | None'; expected 'SupportsKeysAndGetItem[Any, Any]' [arg-type].

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: llm_evals
🔇 Additional comments (5)
docs/data-sources/remote-mcp-servers.md (1)

238-255: LGTM! Clear documentation for stdio transport configuration.

The example effectively demonstrates how to configure Holmes to run a stdio-based MCP process directly without needing Supergateway, including all necessary fields like command, args, and environment variables.

holmes/plugins/toolsets/mcp/toolset_mcp.py (2)

23-57: LGTM! Well-structured base class abstraction.

The BaseMCPTool class provides a clean abstraction for both SSE and stdio transports with proper async handling and shared input schema parsing logic.


98-137: LGTM! Clean stdio implementation following the same pattern as RemoteMCPTool.

The StdioMCPTool class properly implements the stdio transport with appropriate error handling and result formatting.

holmes/plugins/toolsets/__init__.py (1)

40-40: LGTM! Clean factory pattern implementation.

The change from direct RemoteMCPToolset instantiation to using get_mcp_toolset_from_config factory function is a good design pattern that supports multiple transport types while maintaining backward compatibility.

Also applies to: 179-179

tests/test_mcp_toolset.py (1)

124-183: LGTM! Comprehensive test coverage for the factory function.

The tests thoroughly cover all scenarios including:

  • Empty config validation
  • SSE and stdio transport types
  • Backward compatibility with top-level url
  • Edge cases with missing fields

Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)

69-81: Return detailed error info and handle no‑data responses from remote MCP calls.

Aligns with holmes plugins guideline to surface API errors and what was invoked.

Apply this diff:

-                return StructuredToolResult(
-                    status=(
-                        StructuredToolResultStatus.ERROR
-                        if tool_result.isError
-                        else StructuredToolResultStatus.SUCCESS
-                    ),
-                    data=merged_text,
-                    params=params,
-                    invocation=f"MCPtool {self.name} with params {params}",
-                )
+                is_error = bool(getattr(tool_result, "isError", False))
+                has_text = bool(merged_text)
+                status = (
+                    StructuredToolResultStatus.ERROR
+                    if is_error
+                    else (StructuredToolResultStatus.NO_DATA if not has_text else StructuredToolResultStatus.SUCCESS)
+                )
+                return StructuredToolResult(
+                    status=status,
+                    error=merged_text if is_error else (f"No data returned from tool {self.name} at {self.url}" if not has_text else None),
+                    data=None if is_error else merged_text,
+                    params=params,
+                    invocation=f"Remote MCP {self.url} tool {self.name} with params {params!s}",
+                )
🧹 Nitpick comments (4)
tests/test_mcp_toolset.py (1)

56-58: Fix Ruff E712: avoid equality comparison to False.

Use is False or not result[0] to satisfy the linter and improve readability.

Apply this diff:

-    assert result[0] == False
+    assert result[0] is False
holmes/plugins/toolsets/mcp/toolset_mcp.py (3)

107-119: Mirror error/no‑data handling for stdio transport.

Apply this diff:

-                return StructuredToolResult(
-                    status=(
-                        StructuredToolResultStatus.ERROR
-                        if tool_result.isError
-                        else StructuredToolResultStatus.SUCCESS
-                    ),
-                    data=merged_text,
-                    params=params,
-                    invocation=f"Stdio MCP tool {self.name} with params {params}",
-                )
+                is_error = bool(getattr(tool_result, "isError", False))
+                has_text = bool(merged_text)
+                status = (
+                    StructuredToolResultStatus.ERROR
+                    if is_error
+                    else (StructuredToolResultStatus.NO_DATA if not has_text else StructuredToolResultStatus.SUCCESS)
+                )
+                return StructuredToolResult(
+                    status=status,
+                    error=merged_text if is_error else (f"No data returned from stdio tool {self.name} ({self.server_params.command})" if not has_text else None),
+                    data=None if is_error else merged_text,
+                    params=params,
+                    invocation=f"Stdio MCP ({self.server_params.command}) tool {self.name} with params {params!s}",
+                )

94-95: Use explicit f‑string conversion flags and avoid str() inside f‑strings.

Satisfies Ruff RUF010, and improves clarity.

Apply this diff:

-        return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+        return f"Call mcp server {self.url} tool {self.name} with params {params!s}"
@@
-        return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+        return f"Call stdio mcp server tool {self.name} with params {params!s}"
@@
-                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {str(e.args)}",
+                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {e.args!s}",

Also applies to: 136-136, 162-162


26-37: Silence unused-arg warnings (ARG002) without changing behavior.

Keep signatures for framework compatibility but mark params as intentionally unused.

Apply this diff:

 def _invoke(
-        self, params: Dict, user_approved: bool = False
+        self, params: Dict, user_approved: bool = False
     ) -> StructuredToolResult:
+        _ = user_approved  # unused but required by interface
         try:
             return asyncio.run(self._invoke_async(params))
         except Exception as e:
             return StructuredToolResult(
                 status=StructuredToolResultStatus.ERROR,
-                error=str(e.args),
+                error=f"{e.__class__.__name__}: {e}",
                 params=params,
-                invocation=f"{self.__class__.__name__} {self.name} with params {params}",
+                invocation=f"{self.__class__.__name__} {self.name} with params {params!s}",
             )
@@
-    def init_server_tools(self, config: dict[str, Any]) -> Tuple[bool, str]:
+    def init_server_tools(self, config: dict[str, Any]) -> Tuple[bool, str]:
+        _ = config  # unused; signature required by CallablePrerequisite
         try:
             tools_result = asyncio.run(self._get_server_tools())

Also applies to: 149-151

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 31fa18a and 4be1689.

📒 Files selected for processing (2)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (4 hunks)
  • tests/test_mcp_toolset.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • tests/test_mcp_toolset.py
  • holmes/plugins/toolsets/mcp/toolset_mcp.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pytest markers that are declared in pyproject.toml; never introduce undeclared markers/tags

Files:

  • tests/test_mcp_toolset.py
tests/**

📄 CodeRabbit inference engine (CLAUDE.md)

Test layout should mirror the source structure under tests/

Files:

  • tests/test_mcp_toolset.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
🧬 Code graph analysis (2)
tests/test_mcp_toolset.py (2)
holmes/core/tools.py (2)
  • ToolParameter (154-159)
  • Tool (162-353)
holmes/plugins/toolsets/mcp/toolset_mcp.py (7)
  • RemoteMCPToolset (182-220)
  • RemoteMCPTool (59-95)
  • StdioMCPToolset (223-263)
  • get_mcp_toolset_from_config (266-287)
  • init_server_tools (149-163)
  • headers (197-198)
  • url (201-202)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (14)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • ToolParameter (154-159)
  • StructuredToolResultStatus (51-75)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • get_parameterized_one_liner (352-353)
  • get_parameterized_one_liner (379-386)
  • model_post_init (181-215)
  • get_example_config (727-728)
  • get_example_config (746-747)
  • get_example_config (779-780)
🪛 Ruff (0.12.2)
tests/test_mcp_toolset.py

57-57: Avoid equality comparisons to False; use not result[0]: for false checks

Replace with not result[0]

(E712)

holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


95-95: Use explicit conversion flag

Replace with conversion flag

(RUF010)


136-136: Use explicit conversion flag

Replace with conversion flag

(RUF010)


149-149: Unused method argument: config

(ARG002)


158-158: Consider moving this statement to an else block

(TRY300)


159-159: Do not catch blind exception: Exception

(BLE001)


162-162: Use explicit conversion flag

Replace with conversion flag

(RUF010)


232-232: Avoid specifying long messages outside the exception class

(TRY003)


268-268: Avoid specifying long messages outside the exception class

(TRY003)


282-284: Avoid specifying long messages outside the exception class

(TRY003)

🪛 GitHub Actions: Build and test HolmesGPT
tests/test_mcp_toolset.py

[error] 57-57: E712 Avoid equality comparisons to False; use not result[0] for false checks.

holmes/plugins/toolsets/mcp/toolset_mcp.py

[warning] 1-1: pre-commit: Removed unused import 'AnyUrl' from 'from pydantic import AnyUrl, Field, field_validator' to 'from pydantic import Field, field_validator' (lint cleanup).

🪛 GitHub Actions: Evaluate LLM test cases
holmes/plugins/toolsets/mcp/toolset_mcp.py

[error] 182-182: PydanticUserError: Decorators defined with incorrect fields: holmes.plugins.toolsets.mcp.toolset_mcp.RemoteMCPToolset:94303699898320.append_sse_if_missing (use check_fields=False if you're inheriting from the model and intended this). Step: poetry run pytest --no-cov tests/llm/test_ask_holmes.py tests/llm/test_investigate.py -s -n10 -m 'llm and easy'

🔇 Additional comments (4)
tests/test_mcp_toolset.py (2)

130-138: Tests assume SSE URL normalization happens in config; factory must write back normalized URL.

These assertions will only pass if get_mcp_toolset_from_config normalizes /sse and updates toolset.config["url"] (not just a computed property). Ensure the factory mutates/sets config["config"]["url"] to the normalized value and removes any top‑level url. See suggested fixes in toolset_mcp.py.

Also applies to: 153-162


185-201: Authorization header “normalization” expectation is unsupported — no sanitizer found.

No code maps "***" → "Bearer token". Either implement a sanitizer/validator to perform this normalization (e.g., in get_mcp_toolset_from_config or RemoteMCPToolset) or change the test to assert the literal input or pass a real token.

Locations: tests/test_mcp_toolset.py (lines ~185–201); holmes/plugins/toolsets/mcp/toolset_mcp.py (headers property).

holmes/plugins/toolsets/mcp/toolset_mcp.py (2)

26-31: Note: asyncio.run in library code can fail inside a running loop.

If these paths execute under an active event loop (e.g., async runtimes), asyncio.run will raise. Consider an event‑loop aware runner (detect running loop and use asyncio.create_task/await via a bridge) or document the sync‑only contract.

Also applies to: 149-163


1-11: Import cleanup — remove AnyUrl; keep field_validator

field_validator is used at holmes/plugins/toolsets/mcp/toolset_mcp.py:185 (@field_validator("url", mode="before")); remove AnyUrl from the pydantic import on line 11 and keep field_validator.

Likely an incorrect or invalid review comment.

Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py Outdated
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py Outdated
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (3)
holmes/plugins/toolsets/mcp/toolset_mcp.py (3)

185-190: Fix PydanticUserError: validator targets a non-field

The @field_validator("url") will fail with PydanticUserError because url is a property (lines 200-202), not a model field. Remove this validator and implement normalization directly in the url property getter and in the factory function.

Apply this diff to fix the issue:

-from pydantic import Field, field_validator
+from pydantic import Field
 class RemoteMCPToolset(BaseMCPToolset):
     tools: List[RemoteMCPTool] = Field(default_factory=list)  # type: ignore
 
-    @field_validator("url", mode="before")
-    def append_sse_if_missing(cls, v):
-        if isinstance(v, str) and not v.rstrip("/").endswith("/sse"):
-            v = v.rstrip("/") + "/sse"
-        return v
-
     def _create_tools(self, tools: List[MCP_Tool]) -> List[Tool]:

200-202: Normalize SSE URL on property access

The url property should ensure the /sse suffix is always present when accessed, supporting both factory-based and direct constructor usage.

Apply this diff to normalize the URL:

 @property
 def url(self) -> Optional[str]:
-    return self.config and self.config.get("url")
+    raw = self.config and self.config.get("url")
+    if isinstance(raw, str):
+        u = raw.rstrip("/")
+        return u if u.endswith("/sse") else f"{u}/sse"
+    return raw

266-287: Fix backward compatibility and URL normalization in factory

The current implementation has several issues:

  1. Top-level url creates an invalid model with extra field, not nested in config
  2. URL normalization via the field validator won't work (see line 185 issue)
  3. Missing support for top-level command/args for stdio

Apply this comprehensive fix:

 def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
     if not config:
         raise ValueError("Config must not be empty")
-    # Prefer explicit mcp type set in nested 'config' for clarity.
-    # Supported values: 'sse' (remote SSE server) and 'stdio' (stdio server).
-    mcp_config = config.get("config", {})
-    mcp_type = mcp_config.get("type")
-
-    if mcp_type == "sse":
-        return RemoteMCPToolset(**config, name=name)
-    if mcp_type == "stdio":
-        return StdioMCPToolset(**config, name=name)
-
-    # Backwards compatibility: still support using url or command top-level keys.
-    url = config.get("url")
-    if not url:
-        raise ValueError(
-            "MCP Server config must include 'config.type' to specify the transport type."
-        )
-    # fill the mcp server config with URL in case it's not set.
-    mcp_config["url"] = url
-    return RemoteMCPToolset(**config, name=name)
+    
+    # Copy and normalize config
+    cfg = dict(config)
+    mcp_config = dict(cfg.get("config", {}))
+    mcp_type = mcp_config.get("type")
+
+    def _normalize_sse(u: str) -> str:
+        u = u.rstrip("/")
+        return u if u.endswith("/sse") else f"{u}/sse"
+
+    if mcp_type == "sse":
+        if isinstance(mcp_config.get("url"), str):
+            mcp_config["url"] = _normalize_sse(mcp_config["url"])
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
+    if mcp_type == "stdio":
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    # Backwards compatibility: accept top-level url/command and migrate into nested config
+    if "url" in cfg:
+        mcp_config.setdefault("type", "sse")
+        mcp_config["url"] = _normalize_sse(str(cfg.pop("url")))
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
+    if "command" in cfg:
+        mcp_config.setdefault("type", "stdio")
+        mcp_config["command"] = str(cfg.pop("command"))
+        mcp_config["args"] = cfg.pop("args", [])
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    raise ValueError("MCP Server config must include 'config.type' to specify the transport type.")
🧹 Nitpick comments (5)
holmes/plugins/toolsets/mcp/toolset_mcp.py (5)

253-254: Remove redundant parameter construction

The method creates a new StdioServerParameters instance from self.command and self.args, but these properties already derive from self.stdio_server_params. Use the existing self.stdio_server_params directly.

Apply this diff to remove redundancy:

 async def _get_server_tools(self):
-    server_params = StdioServerParameters(command=self.command, args=self.args)
-    async with stdio_client(server_params) as (
+    async with stdio_client(self.stdio_server_params) as (
         read_stream,
         write_stream,
     ):

36-37: Use f-string conversion flag for cleaner formatting

Apply this diff:

-                invocation=f"{self.__class__.__name__} {self.name} with params {params}",
+                invocation=f"{self.__class__.__name__} {self.name} with params {params!s}",

94-95: Use f-string conversion flag

Apply this diff:

-        return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+        return f"Call mcp server {self.url} tool {self.name} with params {params!s}"

135-136: Use f-string conversion flag

Apply this diff:

-        return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+        return f"Call stdio mcp server tool {self.name} with params {params!s}"

162-162: Use f-string conversion flag

Apply this diff:

-                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {str(e.args)}",
+                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {e.args!s}",
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4be1689 and 37294ab.

📒 Files selected for processing (1)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
🧬 Code graph analysis (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (14)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • ToolParameter (154-159)
  • StructuredToolResultStatus (51-75)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • get_parameterized_one_liner (352-353)
  • get_parameterized_one_liner (379-386)
  • model_post_init (181-215)
  • get_example_config (727-728)
  • get_example_config (746-747)
  • get_example_config (779-780)
🪛 Ruff (0.12.2)
holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


95-95: Use explicit conversion flag

Replace with conversion flag

(RUF010)


136-136: Use explicit conversion flag

Replace with conversion flag

(RUF010)


149-149: Unused method argument: config

(ARG002)


158-158: Consider moving this statement to an else block

(TRY300)


159-159: Do not catch blind exception: Exception

(BLE001)


162-162: Use explicit conversion flag

Replace with conversion flag

(RUF010)


232-232: Avoid specifying long messages outside the exception class

(TRY003)


268-268: Avoid specifying long messages outside the exception class

(TRY003)


282-284: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Pre-commit checks
🔇 Additional comments (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)

231-236: LGTM with early config check

The method correctly constructs StdioServerParameters from config. The early validation prevents a TypeError when self.config is None.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)

264-285: Fix backward compatibility and normalize URLs in config.

The backward compatibility path has issues:

  1. When url is at top-level, it's not moved into config, causing "extra inputs" validation error
  2. URL normalization isn't consistent across all paths
  3. The function mutates the input config dict which can cause unexpected side effects

Apply this diff to fix the issues:

 def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
     if not config:
         raise ValueError("Config must not be empty")
+    
+    # Make a copy to avoid mutating the input
+    cfg = dict(config)
+    mcp_config = dict(cfg.get("config", {}))
+    mcp_type = mcp_config.get("type")
     
-    # Prefer explicit mcp type set in nested 'config' for clarity.
-    # Supported values: 'sse' (remote SSE server) and 'stdio' (stdio server).
-    mcp_config = config.get("config", {})
-    mcp_type = mcp_config.get("type")
+    def _normalize_sse(u: str) -> str:
+        u = u.rstrip("/")
+        return u if u.endswith("/sse") else f"{u}/sse"

     if mcp_type == "sse":
-        return RemoteMCPToolset(**config, name=name)
+        if "url" in mcp_config and isinstance(mcp_config["url"], str):
+            mcp_config["url"] = _normalize_sse(mcp_config["url"])
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+        
     if mcp_type == "stdio":
-        return StdioMCPToolset(**config, name=name)
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)

     # Backwards compatibility: still support using url or command top-level keys.
-    url = config.get("url")
-    if not url:
-        raise ValueError(
-            "MCP Server config must include 'config.type' to specify the transport type."
-        )
-    # fill the mcp server config with URL in case it's not set.
-    mcp_config["url"] = url
-    return RemoteMCPToolset(**config, name=name)
+    if "url" in cfg:
+        mcp_config["type"] = "sse"
+        mcp_config["url"] = _normalize_sse(str(cfg.pop("url")))
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+    
+    if "command" in cfg:
+        mcp_config["type"] = "stdio"
+        mcp_config["command"] = str(cfg.pop("command"))
+        mcp_config["args"] = cfg.pop("args", [])
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+    
+    raise ValueError(
+        "MCP Server config must include 'config.type' to specify the transport type."
+    )
🧹 Nitpick comments (8)
holmes/plugins/toolsets/mcp/toolset_mcp.py (6)

27-27: Consider using user_approved parameter or document as future-ready.

The user_approved parameter is currently unused. Either utilize it for authorization checks (e.g., some MCP operations might require approval), document it as reserved for future use, or remove it to align with the abstract base class signature.

 def _invoke(
-    self, params: Dict, user_approved: bool = False
+    self, params: Dict, user_approved: bool = False  # Reserved for future authorization checks
 ) -> StructuredToolResult:

95-95: Use f-string instead of string concatenation.

Static analysis suggests using an f-string for better readability.

-    return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+    return f"Call mcp server {self.url} tool {self.name} with params {params!s}"

136-136: Use f-string conversion flag.

Consider using conversion flag for string formatting.

-    return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+    return f"Call stdio mcp server tool {self.name} with params {params!s}"

149-149: Remove unused parameter or document as required by interface.

The config parameter is unused in init_server_tools. Either use it, document it as required by the CallablePrerequisite interface, or remove it.

-def init_server_tools(self, config: dict[str, Any]) -> Tuple[bool, str]:
+def init_server_tools(self, config: dict[str, Any]) -> Tuple[bool, str]:
+    # config parameter is required by CallablePrerequisite interface but not used here
     try:

159-162: Use specific exception types and handle errors gracefully.

The code catches a broad Exception and uses problematic string conversion with e.args. Consider catching specific exceptions and using str(e) for better error messages.

-    except Exception as e:
+    except (ConnectionError, TimeoutError, ValueError) as e:
         return (
             False,
-            f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {str(e.args)}",
+            f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()}: {e!s}",
         )
+    except Exception as e:
+        return (
+            False,
+            f"Unexpected error loading {self._get_server_type()} mcp server {self.name}: {e!s}",
+        )

251-252: Remove redundant server parameters construction.

You're constructing StdioServerParameters twice - once in stdio_server_params property and again here. Use the property directly.

 async def _get_server_tools(self):
-    server_params = StdioServerParameters(command=self.command, args=self.args)
-    async with stdio_client(server_params) as (
+    async with stdio_client(self.stdio_server_params) as (
         read_stream,
tests/test_mcp_toolset.py (2)

133-137: Ensure URL normalization works correctly in tests.

The test verifies that the URL in config remains unchanged when it already ends with /sse, but doesn't check the normalized toolset.url property. Add an assertion to verify URL normalization.

     assert isinstance(toolset, RemoteMCPToolset)
     assert toolset.name == "test_sse"
     assert str(toolset.config.get("url")) == "http://example.com/sse"
+    assert str(toolset.url) == "http://example.com/sse"  # Verify property normalization

149-150: Add property verification for stdio toolset.

The test verifies config values but doesn't check the exposed properties (command, args).

     assert toolset.config.get("command") == "python"
     assert toolset.config.get("args") == ["/path/to/server.py"]
+    # Verify exposed properties
+    assert toolset.command == "python"
+    assert toolset.args == ["/path/to/server.py"]
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 37294ab and 69fa445.

📒 Files selected for processing (2)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (4 hunks)
  • tests/test_mcp_toolset.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
  • tests/test_mcp_toolset.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pytest markers that are declared in pyproject.toml; never introduce undeclared markers/tags

Files:

  • tests/test_mcp_toolset.py
tests/**

📄 CodeRabbit inference engine (CLAUDE.md)

Test layout should mirror the source structure under tests/

Files:

  • tests/test_mcp_toolset.py
🧬 Code graph analysis (2)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (14)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • ToolParameter (154-159)
  • StructuredToolResultStatus (51-75)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • get_parameterized_one_liner (352-353)
  • get_parameterized_one_liner (379-386)
  • model_post_init (181-215)
  • get_example_config (727-728)
  • get_example_config (746-747)
  • get_example_config (779-780)
tests/test_mcp_toolset.py (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (7)
  • RemoteMCPToolset (182-218)
  • RemoteMCPTool (59-95)
  • StdioMCPToolset (221-261)
  • get_mcp_toolset_from_config (264-285)
  • init_server_tools (149-163)
  • headers (191-192)
  • url (195-200)
🪛 Ruff (0.12.2)
holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


95-95: Use explicit conversion flag

Replace with conversion flag

(RUF010)


136-136: Use explicit conversion flag

Replace with conversion flag

(RUF010)


149-149: Unused method argument: config

(ARG002)


158-158: Consider moving this statement to an else block

(TRY300)


159-159: Do not catch blind exception: Exception

(BLE001)


162-162: Use explicit conversion flag

Replace with conversion flag

(RUF010)


230-230: Avoid specifying long messages outside the exception class

(TRY003)


266-266: Avoid specifying long messages outside the exception class

(TRY003)


280-282: Avoid specifying long messages outside the exception class

(TRY003)

🪛 GitHub Actions: Build and test HolmesGPT
tests/test_mcp_toolset.py

[error] 156-156: RemoteMCPToolset.url: Extra inputs are not permitted. ValidationError loading config (backward compatibility with URL).


[error] 167-167: RemoteMCPToolset.url: Extra inputs are not permitted. ValidationError loading config (URL with SSE).


[error] 200-200: AssertionError: toolset.config['headers']['Authorization'] == '***' (test_mcp_toolset.py:200).

Comment thread tests/test_mcp_toolset.py Outdated
@mainred
mainred requested a review from RoiGlinik September 19, 2025 13:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)

268-290: Fix backward compatibility and validate URL normalization in factory.

The current implementation has several issues:

  1. It mutates the input config dict with config.pop("url", None)
  2. The backward compatibility path doesn't handle SSE URL normalization consistently
  3. The logic flow is confusing with multiple conditional branches

Apply this refactored implementation:

 def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
     if not config:
         raise ValueError("Config must not be empty")
+    
+    # Make a copy to avoid mutating the input
+    cfg = dict(config)
+    mcp_config = dict(cfg.get("config", {}))
+    mcp_type = mcp_config.get("type")
+
+    # Helper function for SSE URL normalization
+    def normalize_sse_url(url: str) -> str:
+        url = url.rstrip("/")
+        return url if url.endswith("/sse") else f"{url}/sse"
+
+    if mcp_type == "stdio":
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    if mcp_type == "sse":
+        # Normalize URL if present
+        if "url" in mcp_config:
+            mcp_config["url"] = normalize_sse_url(mcp_config["url"])
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
     # Backwards compatibility: still support using url or command top-level keys.
-    url = config.pop("url", None)
-    if mcp_type == "sse":
-        return RemoteMCPToolset(**config, name=name)
-    if url is not None:
-        # fill the mcp server config with URL in case the mcp config is not set
-        if mcp_config == {}:
-            config["config"] = {"url": url}
-        else:
-            mcp_config["url"] = url
-        return RemoteMCPToolset(**config, name=name)
+    if "url" in cfg:
+        # Move top-level url to nested config and normalize
+        mcp_config["type"] = "sse"
+        mcp_config["url"] = normalize_sse_url(cfg.pop("url"))
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
 
     raise ValueError(
         "MCP Server config must include a transport type ('sse' or 'stdio') "
         "either in 'config.type' or by providing transport-specific keys."
     )
🧹 Nitpick comments (7)
holmes/plugins/toolsets/mcp/toolset_mcp.py (6)

27-27: Unused argument user_approved in _invoke.

The user_approved parameter is never used in the base implementation. Consider whether this parameter should be passed to _invoke_async for subclasses to utilize, or if it should be removed from the base class signature.

If the parameter is intended for future use by subclasses, consider passing it to _invoke_async:

-    return asyncio.run(self._invoke_async(params))
+    return asyncio.run(self._invoke_async(params, user_approved=user_approved))

And update the abstract method signature:

-async def _invoke_async(self, params: Dict) -> StructuredToolResult:
+async def _invoke_async(self, params: Dict, user_approved: bool = False) -> StructuredToolResult:

95-95: Use explicit string conversion flag for better maintainability.

Consider using an f-string conversion flag for explicit string conversion rather than calling str().

-    return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+    return f"Call mcp server {self.url} tool {self.name} with params {params!s}"

136-136: Use explicit string conversion flag for consistency.

-    return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+    return f"Call stdio mcp server tool {self.name} with params {params!s}"

149-149: Unused parameter config in init_server_tools.

The config parameter is never used. The method signature is defined by the CallablePrerequisite interface which expects a config parameter, but it appears to be unnecessary here.

Consider documenting why the parameter exists if it's required by the interface:

 def init_server_tools(self, config: dict[str, Any]) -> Tuple[bool, str]:
+    # Note: config parameter required by CallablePrerequisite interface but not used
     try:

159-163: Avoid catching broad exceptions and improve error formatting.

The broad Exception catch could mask unexpected errors. Also, converting exception args to string may not provide the best error message.

Consider catching specific exceptions and improving error formatting:

-    except Exception as e:
-        return (
-            False,
-            f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {str(e.args)}",
-        )
+    except Exception as e:
+        error_msg = str(e) if str(e) else repr(e)
+        return (
+            False,
+            f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()}: {error_msg}",
+        )

251-251: Redundant server parameters construction.

The stdio_server_params property already constructs a StdioServerParameters object. Creating it again here is redundant.

-    server_params = StdioServerParameters(command=self.command, args=self.args)
-    async with stdio_client(server_params) as (
+    async with stdio_client(self.stdio_server_params) as (
tests/test_mcp_toolset.py (1)

57-59: Test passes None to init_server_tools but expects non-None behavior.

The test passes config=None to init_server_tools, but based on the implementation, this parameter is unused. This could be confusing for maintainers.

Consider removing the parameter or passing the actual config:

-    result = mcp_toolset.init_server_tools(config=None)
+    result = mcp_toolset.init_server_tools(mcp_toolset.config)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 69fa445 and dbee282.

📒 Files selected for processing (2)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (4 hunks)
  • tests/test_mcp_toolset.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • tests/test_mcp_toolset.py
  • holmes/plugins/toolsets/mcp/toolset_mcp.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pytest markers that are declared in pyproject.toml; never introduce undeclared markers/tags

Files:

  • tests/test_mcp_toolset.py
tests/**

📄 CodeRabbit inference engine (CLAUDE.md)

Test layout should mirror the source structure under tests/

Files:

  • tests/test_mcp_toolset.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
🧬 Code graph analysis (2)
tests/test_mcp_toolset.py (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (7)
  • RemoteMCPToolset (182-218)
  • RemoteMCPTool (59-95)
  • StdioMCPToolset (221-261)
  • get_mcp_toolset_from_config (264-290)
  • init_server_tools (149-163)
  • headers (191-192)
  • url (195-200)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (14)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • ToolParameter (154-159)
  • StructuredToolResultStatus (51-75)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • get_parameterized_one_liner (352-353)
  • get_parameterized_one_liner (379-386)
  • model_post_init (181-215)
  • get_example_config (727-728)
  • get_example_config (746-747)
  • get_example_config (779-780)
🪛 Ruff (0.12.2)
holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


95-95: Use explicit conversion flag

Replace with conversion flag

(RUF010)


136-136: Use explicit conversion flag

Replace with conversion flag

(RUF010)


149-149: Unused method argument: config

(ARG002)


158-158: Consider moving this statement to an else block

(TRY300)


159-159: Do not catch blind exception: Exception

(BLE001)


162-162: Use explicit conversion flag

Replace with conversion flag

(RUF010)


230-230: Avoid specifying long messages outside the exception class

(TRY003)


266-266: Avoid specifying long messages outside the exception class

(TRY003)


287-290: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: llm_evals
  • GitHub Check: Pre-commit checks
🔇 Additional comments (1)
tests/test_mcp_toolset.py (1)

202-202: Test expectation doesn't match actual behavior for Authorization header.

The test expects the raw value "***" but there's no header transformation logic in the code that would change this to "Bearer token" as mentioned in past review comments.

The test correctly validates that the Authorization header value is preserved as-is without transformation.

Comment thread tests/test_mcp_toolset.py
@mainred

mainred commented Sep 24, 2025

Copy link
Copy Markdown
Collaborator Author

cc @arikalon1

# Prefer explicit mcp type set in nested 'config' for clarity.
# Supported values: 'sse' (remote SSE server) and 'stdio' (stdio server).
mcp_config = config.get("config", {})
mcp_type = mcp_config.get("type")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the default be sse?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user specify the url, it's ok but if the user set no type and no url, we'll raise ValidationError instead of
ValueError( "MCP Server config must include a transport type ('sse' or 'stdio') " "either in 'config.type' or by providing transport-specific keys." )

@mainred mainred Sep 25, 2025 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can backfill the type to sse when url is set but type is empty

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that keep it backward compatible is a good idea. The thing that im not sure is clear from the flow is what will happen if the user did provided type:sse and also url: xxx not under the config field. Looks like it won't work right?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
holmes/plugins/toolsets/mcp/toolset_mcp.py (2)

26-37: Avoid asyncio.run in library code; fix error formatting and f-string conversions

Calling asyncio.run can raise at runtime if already inside an event loop. Also use str(e) (or e!s) instead of str(e.args), and add explicit conversion flags in f-strings.

-    def _invoke(
-        self, params: Dict, user_approved: bool = False
-    ) -> StructuredToolResult:
-        try:
-            return asyncio.run(self._invoke_async(params))
-        except Exception as e:
-            return StructuredToolResult(
-                status=StructuredToolResultStatus.ERROR,
-                error=str(e.args),
-                params=params,
-                invocation=f"{self.__class__.__name__} {self.name} with params {params}",
-            )
+    def _invoke(
+        self, params: Dict, user_approved: bool = False
+    ) -> StructuredToolResult:
+        try:
+            return asyncio.run(self._invoke_async(params))
+        except Exception as e:
+            return StructuredToolResult(
+                status=StructuredToolResultStatus.ERROR,
+                error=str(e),
+                params=params,
+                invocation=f"{self.__class__.__name__} {self.name} with params {params!s}",
+            )

If this can run under an existing loop, consider a safe adapter (e.g., anyio) or offloading to a thread. Example helper (place near the top, and use it instead of asyncio.run):

# helper outside the diff range
from concurrent.futures import ThreadPoolExecutor
T = TypeVar("T")

def run_coro_sync(coro: Coroutine[Any, Any, T]) -> T:
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(coro)
    with ThreadPoolExecutor(max_workers=1) as ex:
        return ex.submit(lambda: asyncio.run(coro)).result()

69-81: Propagate error details from CallToolResult

When isError is True, set error accordingly so the UI sees the underlying failure.

-                return StructuredToolResult(
-                    status=(
-                        StructuredToolResultStatus.ERROR
-                        if tool_result.isError
-                        else StructuredToolResultStatus.SUCCESS
-                    ),
-                    data=merged_text,
-                    params=params,
-                    invocation=f"MCPtool {self.name} with params {params}",
-                )
+                is_err = bool(tool_result.isError)
+                return StructuredToolResult(
+                    status=StructuredToolResultStatus.ERROR if is_err else StructuredToolResultStatus.SUCCESS,
+                    error=merged_text if is_err else None,
+                    data=None if is_err else merged_text,
+                    params=params,
+                    invocation=f"MCP tool {self.name} with params {params!s}",
+                )
🧹 Nitpick comments (4)
holmes/plugins/toolsets/mcp/toolset_mcp.py (4)

94-96: Use explicit conversion flag in f-string (RUF010)

Avoid wrapping with str() inside f-strings.

-        return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+        return f"Call mcp server {self.url} tool {self.name} with params {params!s}"

135-136: Use explicit conversion flag in f-string (RUF010)

-        return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+        return f"Call stdio mcp server tool {self.name} with params {params!s}"

146-148: Don’t clobber existing prerequisites; append instead

Preserve any prerequisites defined elsewhere.

-        self.prerequisites = [CallablePrerequisite(callable=self.init_server_tools)]
+        self.prerequisites = list(self.prerequisites or []) + [
+            CallablePrerequisite(callable=self.init_server_tools)
+        ]

149-163: Event loop risk and error formatting in prerequisite loader

asyncio.run may fail under a running loop; also prefer str(e) and conversion flags.

-            tools_result = asyncio.run(self._get_server_tools())
+            tools_result = asyncio.run(self._get_server_tools())
             self.tools = self._create_tools(tools_result.tools)
@@
-            return (
-                False,
-                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {str(e.args)}",
-            )
+            return (
+                False,
+                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {e!s}",
+            )

If this can run under an existing loop, use the same thread-offload helper suggested for BaseMCPTool._invoke.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dbee282 and 70d8e2f.

📒 Files selected for processing (1)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
🧬 Code graph analysis (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (9)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • StructuredToolResultStatus (51-75)
  • ToolParameter (154-159)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • model_post_init (181-215)
🪛 Ruff (0.13.1)
holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


95-95: Use explicit conversion flag

Replace with conversion flag

(RUF010)


136-136: Use explicit conversion flag

Replace with conversion flag

(RUF010)


149-149: Unused method argument: config

(ARG002)


158-158: Consider moving this statement to an else block

(TRY300)


159-159: Do not catch blind exception: Exception

(BLE001)


162-162: Use explicit conversion flag

Replace with conversion flag

(RUF010)


230-230: Avoid specifying long messages outside the exception class

(TRY003)


266-266: Avoid specifying long messages outside the exception class

(TRY003)


288-291: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Pre-commit checks
  • GitHub Check: llm_evals
🔇 Additional comments (3)
holmes/plugins/toolsets/mcp/toolset_mcp.py (3)

227-235: Good: stdio_server_params validates presence of config and filters type

Confirm StdioServerParameters supports all keys you pass from self.config (env, cwd, etc.). If additional keys are present, they’ll raise a validation error — which is desired.


264-291: Do not mutate caller config; normalize and support top-level command/args; normalize SSE URL

This function pops "url" from the input and doesn’t handle top-level command/args. It should copy, normalize, and write into nested config without side effects.

-def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
-    if not config:
-        raise ValueError("Config must not be empty")
-    # Prefer explicit mcp type set in nested 'config' for clarity.
-    # Supported values: 'sse' (remote SSE server) and 'stdio' (stdio server).
-    mcp_config = config.get("config", {})
-    mcp_type = mcp_config.get("type")
-
-    if mcp_type == "stdio":
-        return StdioMCPToolset(**config, name=name)
-
-    # Backwards compatibility: still support using url or command top-level keys.
-    url = config.pop("url", None)
-    if mcp_type == "sse":
-        return RemoteMCPToolset(**config, name=name)
-    if url is not None:
-        # fill the mcp server config with URL in case the mcp config is not set
-        if mcp_config == {}:
-            config["config"] = {"url": url}
-        else:
-            mcp_config["url"] = url
-        mcp_config["type"] = "sse"
-        return RemoteMCPToolset(**config, name=name)
-
-    raise ValueError(
-        "MCP Server config must include a transport type ('sse' or 'stdio') "
-        "either in 'config.type' or by providing transport-specific keys."
-    )
+def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
+    if not config:
+        raise ValueError("Config must not be empty")
+    cfg = dict(config)
+    mcp_config = dict(cfg.get("config", {}))
+    mcp_type = mcp_config.get("type")
+
+    def _normalize_sse(u: str) -> str:
+        u = u.rstrip("/")
+        return u if u.endswith("/sse") else f"{u}/sse"
+
+    if mcp_type == "sse":
+        if isinstance(mcp_config.get("url"), str):
+            mcp_config["url"] = _normalize_sse(mcp_config["url"])
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
+    if mcp_type == "stdio":
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    # Back-compat: migrate top-level url/command/args
+    if "url" in cfg:
+        mcp_config.setdefault("type", "sse")
+        mcp_config["url"] = _normalize_sse(str(cfg.pop("url")))
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
+    if "command" in cfg:
+        mcp_config.setdefault("type", "stdio")
+        mcp_config["command"] = str(cfg.pop("command"))
+        mcp_config["args"] = cfg.pop("args", [])
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    raise ValueError(
+        "MCP Server config must include a transport type ('sse' or 'stdio') either in 'config.type' or by providing transport-specific keys."
+    )

5-5: No change required: top-level import of StdioServerParameters is valid.

Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
holmes/plugins/toolsets/mcp/toolset_mcp.py (3)

71-83: Propagate errors from remote CallToolResult; include URL in invocation

Current code sets status=ERROR but still returns data and no error. Return error on failure and include connection info.

-                merged_text = " ".join(
-                    c.text for c in tool_result.content if c.type == "text"
-                )
-                return StructuredToolResult(
-                    status=(
-                        StructuredToolResultStatus.ERROR
-                        if tool_result.isError
-                        else StructuredToolResultStatus.SUCCESS
-                    ),
-                    data=merged_text,
-                    params=params,
-                    invocation=f"MCPtool {self.name} with params {params}",
-                )
+                merged_text = " ".join(c.text for c in tool_result.content if c.type == "text")
+                is_err = bool(tool_result.isError)
+                return StructuredToolResult(
+                    status=StructuredToolResultStatus.ERROR if is_err else StructuredToolResultStatus.SUCCESS,
+                    error=merged_text if is_err else None,
+                    data=None if is_err else merged_text,
+                    params=params,
+                    invocation=f"Remote MCP tool {self.name} @ {self.url} with params {params!s}",
+                )

210-218: Guard against missing URL when listing remote tools

Same str(None) issue in connection setup.

-    async def _get_server_tools(self):
-        async with sse_client(str(self.url), headers=self.headers) as (
+    async def _get_server_tools(self):
+        url = self.url
+        if not url:
+            raise ValueError("Remote MCP toolset requires a non-empty URL")
+        async with sse_client(url, headers=self.headers) as (
             read_stream,
             write_stream,
         ):
             async with ClientSession(read_stream, write_stream) as session:
                 _ = await session.initialize()
                 return await session.list_tools()

265-293: Fix backward-compat and avoid mutating input; also normalize SSE URLs and support top-level command/args

  • Current early return when mcp_type == "sse" drops a top-level url (already popped), breaking configs that rely on it.
  • Do not mutate the passed config; copy and migrate top-level keys into nested config.
  • Normalize SSE URLs by appending /sse when missing.
  • Support top-level command/args for stdio as advertised.
-def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
-    if not config:
-        raise ValueError("Config must not be empty")
-    # Prefer explicit mcp type set in nested 'config' for clarity.
-    # Supported values: 'sse' (remote SSE server) and 'stdio' (stdio server).
-    mcp_config = config.get("config", {})
-    mcp_type = mcp_config.get("type")
-
-    if mcp_type == "stdio":
-        return StdioMCPToolset(**config, name=name)
-
-    # Backwards compatibility: still support using url or command top-level keys.
-    url = config.pop("url", None)
-    if mcp_type == "sse":
-        return RemoteMCPToolset(**config, name=name)
-    if url is not None:
-        # fill the mcp server config with URL in case the mcp config is not set
-        if mcp_config == {}:
-            config["config"] = {"url": url}
-        else:
-            mcp_config["url"] = url
-        mcp_config["type"] = "sse"
-        return RemoteMCPToolset(**config, name=name)
-
-    raise ValueError(
-        "MCP Server config must include a transport type ('sse' or 'stdio') "
-        "either in 'config.type' or by providing transport-specific keys."
-    )
+def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
+    if not config:
+        raise ValueError("Config must not be empty")
+
+    cfg = dict(config)
+    mcp_config = dict(cfg.get("config", {}))
+    mcp_type = mcp_config.get("type")
+
+    def _normalize_sse(u: str) -> str:
+        u = u.rstrip("/")
+        return u if u.endswith("/sse") else f"{u}/sse"
+
+    if mcp_type == "stdio":
+        # Ensure stdio params reside in nested config; accept top-level fallbacks.
+        if "command" not in mcp_config and "command" in cfg:
+            mcp_config["command"] = str(cfg.pop("command"))
+            mcp_config["args"] = cfg.pop("args", [])
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    if mcp_type == "sse":
+        # Ensure URL present; accept top-level fallback and normalize.
+        if "url" not in mcp_config and "url" in cfg:
+            mcp_config["url"] = str(cfg.pop("url"))
+        if isinstance(mcp_config.get("url"), str):
+            mcp_config["url"] = _normalize_sse(mcp_config["url"])
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
+    # Backward-compat: infer transport from top-level keys.
+    if "url" in cfg:
+        mcp_config.setdefault("type", "sse")
+        mcp_config["url"] = _normalize_sse(str(cfg.pop("url")))
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
+    if "command" in cfg:
+        mcp_config.setdefault("type", "stdio")
+        mcp_config["command"] = str(cfg.pop("command"))
+        mcp_config["args"] = cfg.pop("args", [])
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    raise ValueError(
+        "MCP Server config must include a transport type ('sse' or 'stdio') "
+        "either in 'config.type' or by providing transport-specific keys."
+    )
🧹 Nitpick comments (5)
holmes/plugins/toolsets/mcp/toolset_mcp.py (5)

43-59: Allow inputSchema to be None (type hint + guard)

Aligns with remote servers that omit inputSchema and avoids mypy/type errors.

 @classmethod
 def parse_input_schema(
-        cls, input_schema: dict[str, Any]
+        cls, input_schema: Optional[dict[str, Any]]
 ) -> Dict[str, ToolParameter]:
     if not input_schema:
         return {}

96-98: Use explicit conversion flag in f-string (RUF010)

Minor formatting nit.

-        return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+        return f"Call mcp server {self.url} tool {self.name} with params {params!s}"

137-139: Use explicit conversion flag in f-string (RUF010)

Minor formatting nit.

-        return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+        return f"Call stdio mcp server tool {self.name} with params {params!s}"

151-166: Silence unused config param and improve error message text

Rename to underscore and avoid tuple-ified exception text.

-    def init_server_tools(self, config: dict[str, Any]) -> Tuple[bool, str]:
+    def init_server_tools(self, _config: dict[str, Any]) -> Tuple[bool, str]:
@@
-        except Exception as e:
+        except Exception as e:
             return (
                 False,
-                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {str(e.args)}",
+                f"Failed to load {self._get_server_type()} mcp server {self.name} {self._get_connection_info()} {e!s}",
             )

26-38: Rename user_approved to _user_approved, use str(e) for errors, and add !s on params; asyncio.run is safe

  • Rename the unused user_approved parameter to _user_approved.
  • Change error=str(e.args) to error=str(e) and update invocation to
    f"{self.__class__.__name__} {self.name} with params {params!s}".
    No calls to _invoke occur within async functions, so using asyncio.run here is safe.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 70d8e2f and 8eaad16.

📒 Files selected for processing (1)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
🧬 Code graph analysis (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (14)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • StructuredToolResultStatus (51-75)
  • ToolParameter (154-159)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • get_parameterized_one_liner (352-353)
  • get_parameterized_one_liner (379-386)
  • model_post_init (181-215)
  • get_example_config (727-728)
  • get_example_config (746-747)
  • get_example_config (779-780)
🪛 Ruff (0.13.1)
holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


97-97: Use explicit conversion flag

Replace with conversion flag

(RUF010)


138-138: Use explicit conversion flag

Replace with conversion flag

(RUF010)


151-151: Unused method argument: config

(ARG002)


160-160: Consider moving this statement to an else block

(TRY300)


161-161: Do not catch blind exception: Exception

(BLE001)


164-164: Use explicit conversion flag

Replace with conversion flag

(RUF010)


232-232: Avoid specifying long messages outside the exception class

(TRY003)


267-267: Avoid specifying long messages outside the exception class

(TRY003)


289-292: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: llm_evals
  • GitHub Check: Pre-commit checks
🔇 Additional comments (2)
holmes/plugins/toolsets/mcp/toolset_mcp.py (2)

187-191: Guard against missing URL when creating RemoteMCPTool instances

Avoid str(None) → "None" and confusing connection attempts.

-        return [
-            RemoteMCPTool.create(str(self.url), tool, self.headers) for tool in tools
-        ]
+        url = self.url
+        if not url:
+            raise ValueError("Remote MCP toolset requires a non-empty URL")
+        return [RemoteMCPTool.create(url, tool, self.headers) for tool in tools]

103-121: Propagate errors from stdio CallToolResult; include command/args in invocation

Mirror SSE behavior for error propagation and add connection info.

-                merged_text = " ".join(
-                    c.text for c in tool_result.content if c.type == "text"
-                )
-                return StructuredToolResult(
-                    status=(
-                        StructuredToolResultStatus.ERROR
-                        if tool_result.isError
-                        else StructuredToolResultStatus.SUCCESS
-                    ),
-                    data=merged_text,
-                    params=params,
-                    invocation=f"Stdio MCP tool {self.name} with params {params}",
-                )
+                merged_text = " ".join(c.text for c in tool_result.content if c.type == "text")
+                is_err = bool(tool_result.isError)
+                return StructuredToolResult(
+                    status=StructuredToolResultStatus.ERROR if is_err else StructuredToolResultStatus.SUCCESS,
+                    error=merged_text if is_err else None,
+                    data=None if is_err else merged_text,
+                    params=params,
+                    invocation=f"Stdio MCP tool {self.name} ({self.server_params.command} {' '.join(self.server_params.args)}) with params {params!s}",
+                )

@moshemorad moshemorad left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job. couple of small comments.

Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
Comment thread docs/data-sources/remote-mcp-servers.md Outdated
server_params: StdioServerParameters

async def _invoke_async(self, params: Dict) -> StructuredToolResult:
async with stdio_client(self.server_params) as (read_stream, write_stream):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to create the process for each tool call?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, we can reuse the same session.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sharing the session keeps raise the following error. I need more time to investigate

Traceback (most recent call last):
  File "/home/azureuser/.cache/pypoetry/virtualenvs/holmesgpt-N4JKwMcz-py3.12/lib/python3.12/site-packages/mcp/client/stdio/__init__.py", line 180, in
stdio_client
    async with (
  File "/home/azureuser/.cache/pypoetry/virtualenvs/holmesgpt-N4JKwMcz-py3.12/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 778, in
__aexit__
    if self.cancel_scope.__exit__(type(exc), exc, exc.__traceback__):
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/azureuser/.cache/pypoetry/virtualenvs/holmesgpt-N4JKwMcz-py3.12/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 457, in
__exit__
    raise RuntimeError(
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
``

Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py Outdated
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py Outdated
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
# Prefer explicit mcp type set in nested 'config' for clarity.
# Supported values: 'sse' (remote SSE server) and 'stdio' (stdio server).
mcp_config = config.get("config", {})
mcp_type = mcp_config.get("type")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that keep it backward compatible is a good idea. The thing that im not sure is clear from the flow is what will happen if the user did provided type:sse and also url: xxx not under the config field. Looks like it won't work right?

Comment thread tests/test_mcp_toolset.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
holmes/plugins/toolsets/mcp/toolset_mcp.py (2)

71-84: Set data=None on error; keep merged text in error.

Avoids populating both data and error simultaneously.

-                return StructuredToolResult(
-                    status=(
-                        StructuredToolResultStatus.ERROR
-                        if tool_result.isError
-                        else StructuredToolResultStatus.SUCCESS
-                    ),
-                    error=merged_text if tool_result.isError else None,
-                    data=merged_text,
-                    params=params,
-                    invocation=f"MCPtool {self.name} with params {params}",
-                )
+                is_err = bool(tool_result.isError)
+                return StructuredToolResult(
+                    status=StructuredToolResultStatus.ERROR if is_err else StructuredToolResultStatus.SUCCESS,
+                    error=merged_text if is_err else None,
+                    data=None if is_err else merged_text,
+                    params=params,
+                    invocation=f"MCPtool {self.name} with params {params!r}",
+                )

211-218: Validate URL before connecting in list_tools path.

Prevents sse_client("None") if misconfigured.

-        async with sse_client(str(self.url), headers=self.headers) as (
+        url = self.url
+        if not url:
+            raise ValueError("Remote MCP toolset requires a non-empty URL")
+        async with sse_client(url, headers=self.headers) as (
             read_stream,
             write_stream,
         ):
🧹 Nitpick comments (10)
docs/data-sources/remote-mcp-servers.md (4)

22-27: Fix heading style (MD003) and prefer ATX headers.

Convert the setext header to ATX to satisfy markdownlint.

-Transport selection and supported fields
---------------------------------------
+## Transport selection and supported fields

54-60: Fix heading style (MD003) and add a blank line before the list.

Current setext header plus immediate list violates our MkDocs rule.

-Security notes
---------------
-- Avoid placing secrets directly in versioned config files. Prefer environment
+## Security notes
+
+- Avoid placing secrets directly in versioned config files. Prefer environment
   variables or Holmes' secret interpolation for API keys and tokens.
-- Holmes strips transport-only and common sensitive fields (for example
-  `type` and `key`) from subprocess parameters when constructing `StdioServerParameters`.
+- Holmes strips transport-only and common sensitive fields (for example
+  `type`) from subprocess parameters when constructing `StdioServerParameters`.

Note: The code currently only strips "type" in StdioMCPToolset; if you want to strip "key" too, align the code accordingly (see toolset_mcp.py suggestion).


96-96: Typo: “supergatway” → “Supergateway”.

-Check out supergatway docs to find out other useful flags.
+Check out Supergateway docs to find out other useful flags.

221-221: Fix formatting typo.

Remove stray asterisks in bolded filename.

-Alternatively, you can add the `mcp_servers` configurations to **custom_toolset.yaml:***, and run:
+Alternatively, you can add the `mcp_servers` configurations to **custom_toolset.yaml**, and run:
tests/test_mcp_toolset.py (1)

216-225: Add test for top‑level command backward‑compat to match docs.

Current tests don’t cover top-level command. Add this to prevent regressions once the factory supports it.

+def test_get_mcp_toolset_from_config_backward_compatibility_with_top_level_command():
+    """Top-level command/args should be migrated into nested config for stdio."""
+    config = {"command": "node", "args": ["server.js"]}
+    toolset = get_mcp_toolset_from_config(config, "test_stdio_backward")
+    assert isinstance(toolset, StdioMCPToolset)
+    assert toolset.name == "test_stdio_backward"
+    assert toolset.config.get("command") == "node"
+    assert toolset.config.get("args") == ["server.js"]
holmes/plugins/toolsets/mcp/toolset_mcp.py (5)

26-38: Use str(e) (not e.args), and mark unused param; improve invocation formatting.

Prevents tuple-y error strings and satisfies Ruff RUF010.

 def _invoke(
-        self, params: Dict, user_approved: bool = False
+        self, params: Dict, user_approved: bool = False
     ) -> StructuredToolResult:
         try:
             return asyncio.run(self._invoke_async(params))
         except Exception as e:
             return StructuredToolResult(
                 status=StructuredToolResultStatus.ERROR,
-                error=str(e.args),
+                error=str(e),
                 params=params,
-                invocation=f"{self.__class__.__name__} {self.name} with params {params}",
+                invocation=f"{self.__class__.__name__} {self.name} with params {params!r}",
             )

43-59: Accept Optional inputSchema to avoid AttributeError/mypy issues.

Remote servers may omit inputSchema; signature should reflect Optional.

-    def parse_input_schema(
-        cls, input_schema: dict[str, Any]
-    ) -> Dict[str, ToolParameter]:
+    def parse_input_schema(
+        cls, input_schema: Optional[dict[str, Any]]
+    ) -> Dict[str, ToolParameter]:
         if not input_schema:
             return {}

97-98: Use explicit conversion flag in f-string.

Satisfies Ruff RUF010 and avoids manual str().

-        return f"Call mcp server {self.url} tool {self.name} with params {str(params)}"
+        return f"Call mcp server {self.url} tool {self.name} with params {params!r}"

139-141: Use explicit conversion flag in f-string.

-        return f"Call stdio mcp server tool {self.name} with params {str(params)}"
+        return f"Call stdio mcp server tool {self.name} with params {params!r}"

231-237: Strip additional non-stdio keys before constructing StdioServerParameters.

Docs imply sensitive keys are stripped. Also avoids unexpected kwargs.

         params = dict(self.config)
         # pop out type which is not a parameter of StdioServerParameters
-        params.pop("type", None)
+        params.pop("type", None)
+        # defensively drop unrelated keys that could be present
+        for k in ("key", "headers", "url"):
+            params.pop(k, None)
         return StdioServerParameters(**params)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8eaad16 and 48bc8d1.

📒 Files selected for processing (3)
  • docs/data-sources/remote-mcp-servers.md (3 hunks)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (5 hunks)
  • tests/test_mcp_toolset.py (4 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
docs/**/*.md

📄 CodeRabbit inference engine (CLAUDE.md)

In MkDocs content, always add a blank line between a header/bold text and a list to render lists properly

Files:

  • docs/data-sources/remote-mcp-servers.md
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
All Python code must include type hints (mypy enforced)

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
  • tests/test_mcp_toolset.py
holmes/plugins/toolsets/**

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/{core,plugins}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

All tools must return detailed error messages from underlying APIs, including executed command/query, time ranges/parameters, and full API error response; 'no data' responses must specify what was searched and where

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pytest markers that are declared in pyproject.toml; never introduce undeclared markers/tags

Files:

  • tests/test_mcp_toolset.py
tests/**

📄 CodeRabbit inference engine (CLAUDE.md)

Test layout should mirror the source structure under tests/

Files:

  • tests/test_mcp_toolset.py
🧠 Learnings (3)
📚 Learning: 2025-09-15T07:09:16.052Z
Learnt from: CR
PR: robusta-dev/holmesgpt#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-15T07:09:16.052Z
Learning: Applies to {holmes/plugins/toolsets/**/*.yaml,tests/llm/**/toolsets.yaml} : Valid top-level fields for toolset YAMLs are limited to: enabled, name, description, additional_instructions, prerequisites, tools, docs_url, icon_url, installation_instructions, config, url (MCP only)

Applied to files:

  • docs/data-sources/remote-mcp-servers.md
📚 Learning: 2025-08-05T00:42:23.792Z
Learnt from: vishiy
PR: robusta-dev/holmesgpt#782
File: config.example.yaml:31-49
Timestamp: 2025-08-05T00:42:23.792Z
Learning: In robusta-dev/holmesgpt config.example.yaml, the azuremonitorlogs toolset configuration shows "enabled: true" as an example of how to enable the toolset, not as a default setting. The toolset is disabled by default and requires explicit enablement in user configurations.

Applied to files:

  • docs/data-sources/remote-mcp-servers.md
📚 Learning: 2025-09-15T07:09:16.052Z
Learnt from: CR
PR: robusta-dev/holmesgpt#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-15T07:09:16.052Z
Learning: Applies to holmes/plugins/toolsets/** : Toolsets must be organized under holmes/plugins/toolsets/ as either {name}.yaml files or directories

Applied to files:

  • docs/data-sources/remote-mcp-servers.md
🧬 Code graph analysis (2)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (11)
  • Tool (162-353)
  • CallablePrerequisite (507-508)
  • StructuredToolResult (78-102)
  • StructuredToolResultStatus (51-75)
  • ToolParameter (154-159)
  • Toolset (520-735)
  • _invoke (342-349)
  • _invoke (402-432)
  • get_parameterized_one_liner (352-353)
  • get_parameterized_one_liner (379-386)
  • model_post_init (181-215)
tests/test_mcp_toolset.py (2)
holmes/core/tools.py (2)
  • Tool (162-353)
  • ToolParameter (154-159)
holmes/plugins/toolsets/mcp/toolset_mcp.py (7)
  • RemoteMCPTool (61-98)
  • RemoteMCPToolset (185-221)
  • StdioMCPToolset (224-263)
  • get_mcp_toolset_from_config (266-297)
  • init_server_tools (152-166)
  • headers (194-195)
  • url (198-203)
🪛 markdownlint-cli2 (0.18.1)
docs/data-sources/remote-mcp-servers.md

22-22: Heading style
Expected: atx; Actual: setext

(MD003, heading-style)


54-54: Heading style
Expected: atx; Actual: setext

(MD003, heading-style)

🪛 Ruff (0.13.1)
holmes/plugins/toolsets/mcp/toolset_mcp.py

27-27: Unused method argument: user_approved

(ARG002)


98-98: Use explicit conversion flag

Replace with conversion flag

(RUF010)


140-140: Use explicit conversion flag

Replace with conversion flag

(RUF010)


152-152: Unused method argument: config

(ARG002)


161-161: Consider moving this statement to an else block

(TRY300)


162-162: Do not catch blind exception: Exception

(BLE001)


165-165: Use explicit conversion flag

Replace with conversion flag

(RUF010)


233-233: Avoid specifying long messages outside the exception class

(TRY003)


268-268: Avoid specifying long messages outside the exception class

(TRY003)


294-297: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: llm_evals
  • GitHub Check: Pre-commit checks
🔇 Additional comments (4)
holmes/plugins/toolsets/mcp/toolset_mcp.py (4)

205-210: Optional: deprecation clarity for server type.

Consider returning "remote" vs "stdio" in logs, or explicitly "sse" as done here; OK to keep.


253-261: Good: preserves full stdio parameters when listing tools.


188-191: Guard against missing URL; avoid str(None) → "None".

Raise a clear error instead of attempting to connect to "None".

-        return [
-            RemoteMCPTool.create(str(self.url), tool, self.headers) for tool in tools
-        ]
+        url = self.url
+        if not url:
+            raise ValueError("Remote MCP toolset requires a non-empty URL")
+        return [RemoteMCPTool.create(url, tool, self.headers) for tool in tools]

104-124: Mirror error handling for stdio: data=None on error; improve invocation.

Aligns with SSE behavior and avoids dual data/error.

-                merged_text = " ".join(
-                    c.text for c in tool_result.content if c.type == "text"
-                )
-                return StructuredToolResult(
-                    status=(
-                        StructuredToolResultStatus.ERROR
-                        if tool_result.isError
-                        else StructuredToolResultStatus.SUCCESS
-                    ),
-                    error=merged_text if tool_result.isError else None,
-                    data=merged_text,
-                    params=params,
-                    invocation=f"Stdio MCP tool {self.name} with params {params}",
-                )
+                merged_text = " ".join(c.text for c in tool_result.content if c.type == "text")
+                is_err = bool(tool_result.isError)
+                return StructuredToolResult(
+                    status=StructuredToolResultStatus.ERROR if is_err else StructuredToolResultStatus.SUCCESS,
+                    error=merged_text if is_err else None,
+                    data=None if is_err else merged_text,
+                    params=params,
+                    invocation=f"Stdio MCP tool {self.name} with params {params!r}",
+                )

Comment thread docs/data-sources/remote-mcp-servers.md
Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
@moshemorad
moshemorad self-requested a review October 5, 2025 08:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)

211-218: Guard against None URL before calling sse_client.

If self.url is None, str(self.url) becomes the string "None", causing connection failures.

Apply this diff:

     async def _get_server_tools(self):
-        async with sse_client(str(self.url), headers=self.headers) as (
+        url = self.url
+        if not url:
+            raise ValueError("Remote MCP toolset requires a non-empty URL")
+        async with sse_client(url, headers=self.headers) as (
             read_stream,
             write_stream,
         ):
♻️ Duplicate comments (3)
holmes/plugins/toolsets/mcp/toolset_mcp.py (3)

188-191: Guard against None URL before creating tools.

If self.url is None, str(self.url) becomes the string "None", causing confusing connection attempts.

Apply this diff:

     def _create_tools(self, tools: List[MCP_Tool]) -> List[Tool]:
+        url = self.url
+        if not url:
+            raise ValueError("Remote MCP toolset requires a non-empty URL")
-        return [
-            RemoteMCPTool.create(str(self.url), tool, self.headers) for tool in tools
-        ]
+        return [RemoteMCPTool.create(url, tool, self.headers) for tool in tools]

Based on learnings


119-120: Set data to None when isError is True.

Currently, both error and data are set to merged_text when an error occurs. The error details should only populate the error field, leaving data as None.

Apply this diff:

-                    error=merged_text if tool_result.isError else None,
-                    data=merged_text,
+                    error=merged_text if tool_result.isError else None,
+                    data=None if tool_result.isError else merged_text,

266-297: Do not mutate input config; support top-level command/args; add deprecation warnings; normalize SSE URL.

Current code has several critical issues:

  1. Line 278: config.pop() mutates the caller's input, causing side effects.
  2. Missing support for top-level command/args keys (documented in examples).
  3. No deprecation warnings for backward-compatibility paths.
  4. SSE URL normalization not consistently applied.

Apply this diff to fix all issues:

 def get_mcp_toolset_from_config(config: dict[str, Any], name: str) -> BaseMCPToolset:
     if not config:
         raise ValueError("Config must not be empty")
-    # Prefer explicit mcp type set in nested 'config' for clarity.
-    # Supported values: 'sse' (remote SSE server) and 'stdio' (stdio server).
-    mcp_config = config.get("config", {})
-    mcp_type = mcp_config.get("type")
-
-    if mcp_type == "stdio":
-        return StdioMCPToolset(**config, name=name)
-
-    # DEPRECATED: support top-level url key for backward compatibility will be removed
-    url = config.pop("url", None)
-    # In case the user combine the new and old config styles with top-level url key and sse as config.type
-    if mcp_type == "sse":
-        if url is not None:
-            mcp_config["url"] = url
-        return RemoteMCPToolset(**config, name=name)
-    # When config.type is empty and url is set, assume sse transport.
-    if url is not None:
-        # fill the mcp server config with URL in case the mcp config is not set
-        if mcp_config == {}:
-            config["config"] = {"url": url}
-        else:
-            mcp_config["url"] = url
-        mcp_config["type"] = "sse"
-        return RemoteMCPToolset(**config, name=name)
-
-    raise ValueError(
-        "MCP Server config must include a transport type ('sse' or 'stdio') "
-        "either in 'config.type' or by providing transport-specific keys."
-    )
+
+    cfg = dict(config)  # avoid mutating caller input
+    mcp_config = dict(cfg.get("config", {}))
+    mcp_type = mcp_config.get("type")
+
+    def _normalize_sse(u: str) -> str:
+        u = u.rstrip("/")
+        return u if u.endswith("/sse") else f"{u}/sse"
+
+    if mcp_type == "stdio":
+        # allow mixing: promote any top-level command/args into nested config
+        if "command" in cfg and "command" not in mcp_config:
+            logging.warning("DEPRECATED: top-level 'command' will be removed; use config.command")
+            mcp_config["command"] = str(cfg.pop("command"))
+        if "args" in cfg and "args" not in mcp_config:
+            logging.warning("DEPRECATED: top-level 'args' will be removed; use config.args")
+            mcp_config["args"] = cfg.pop("args")
+        cfg["config"] = mcp_config
+        return StdioMCPToolset(**cfg, name=name)
+
+    # DEPRECATED: support top-level url/command for backward compatibility
+    top_url = cfg.pop("url", None)
+    top_cmd = cfg.pop("command", None)
+    top_args = cfg.pop("args", None)
+
+    if mcp_type == "sse":
+        if top_url is not None and "url" not in mcp_config:
+            logging.warning("DEPRECATED: top-level 'url' will be removed; use config.url")
+            mcp_config["url"] = top_url
+        if isinstance(mcp_config.get("url"), str):
+            mcp_config["url"] = _normalize_sse(mcp_config["url"])
+        cfg["config"] = mcp_config
+        return RemoteMCPToolset(**cfg, name=name)
+
+    if mcp_type is None:
+        if top_url is not None:
+            logging.warning("DEPRECATED: inferring 'sse' from top-level 'url'; set config.type")
+            mcp_config["type"] = "sse"
+            mcp_config["url"] = _normalize_sse(str(top_url))
+            cfg["config"] = mcp_config
+            return RemoteMCPToolset(**cfg, name=name)
+        if top_cmd is not None:
+            logging.warning("DEPRECATED: inferring 'stdio' from top-level 'command'; set config.type")
+            mcp_config["type"] = "stdio"
+            mcp_config["command"] = str(top_cmd)
+            if top_args is not None:
+                mcp_config["args"] = top_args
+            cfg["config"] = mcp_config
+            return StdioMCPToolset(**cfg, name=name)
+
+    raise ValueError(
+        "MCP Server config must include a transport type ('sse' or 'stdio') "
+        "either in 'config.type' or by providing transport-specific keys."
+    )
🧹 Nitpick comments (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)

103-103: Consider reusing the stdio client session.

The TODO correctly identifies a performance concern: spawning a new process per tool invocation is inefficient.

Do you want me to investigate session reuse patterns or open an issue to track this optimization?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 48bc8d1 and d5ce67a.

📒 Files selected for processing (2)
  • holmes/plugins/toolsets/__init__.py (2 hunks)
  • holmes/plugins/toolsets/mcp/toolset_mcp.py (5 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • holmes/plugins/toolsets/init.py
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Use Ruff for formatting and linting (configured in pyproject.toml) for all Python code
Type hints are required; code should pass mypy (configured in pyproject.toml)
ALWAYS place Python imports at the top of the file, not inside functions or methods

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
holmes/plugins/toolsets/**/*

📄 CodeRabbit inference engine (CLAUDE.md)

Toolsets must be located as holmes/plugins/toolsets/{name}.yaml or holmes/plugins/toolsets/{name}/

Files:

  • holmes/plugins/toolsets/mcp/toolset_mcp.py
🧬 Code graph analysis (1)
holmes/plugins/toolsets/mcp/toolset_mcp.py (1)
holmes/core/tools.py (13)
  • Tool (173-365)
  • CallablePrerequisite (521-522)
  • StructuredToolResult (79-103)
  • StructuredToolResultStatus (52-76)
  • ToolInvokeContext (164-170)
  • ToolParameter (155-161)
  • Toolset (534-768)
  • get_parameterized_one_liner (364-365)
  • get_parameterized_one_liner (391-398)
  • model_post_init (192-226)
  • get_example_config (760-761)
  • get_example_config (779-780)
  • get_example_config (812-813)
🪛 Ruff (0.13.3)
holmes/plugins/toolsets/mcp/toolset_mcp.py

97-97: Use explicit conversion flag

Replace with conversion flag

(RUF010)


140-140: Use explicit conversion flag

Replace with conversion flag

(RUF010)


152-152: Unused method argument: config

(ARG002)


161-161: Consider moving this statement to an else block

(TRY300)


162-162: Do not catch blind exception: Exception

(BLE001)


165-165: Use explicit conversion flag

Replace with conversion flag

(RUF010)


233-233: Avoid specifying long messages outside the exception class

(TRY003)


268-268: Avoid specifying long messages outside the exception class

(TRY003)


294-297: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: llm_evals
  • GitHub Check: Pre-commit checks
  • GitHub Check: build
🔇 Additional comments (4)
holmes/plugins/toolsets/mcp/toolset_mcp.py (4)

42-57: LGTM! inputSchema guard properly implemented.

The parse_input_schema method now correctly handles None or missing inputSchema by returning an empty dict, addressing the previous review comment.


197-203: LGTM! SSE URL normalization correctly implemented.

The property correctly normalizes the URL by ensuring the /sse suffix is present, addressing the previous review comment.


230-237: LGTM! Config guard properly implemented.

The property now correctly guards against None config before constructing StdioServerParameters, addressing the previous review comment.


253-260: LGTM! Full stdio parameters preserved.

The method now correctly uses self.stdio_server_params to preserve all parameters (env, cwd, etc.), addressing the previous review comment.

Comment thread holmes/plugins/toolsets/mcp/toolset_mcp.py
@github-actions

github-actions Bot commented Nov 4, 2025

Copy link
Copy Markdown
Contributor

Results of HolmesGPT evals

  • ask_holmes: 30/36 test cases were successful, 5 regressions, 1 setup failures
Test suite Test case Status
ask 01_how_many_pods ✅
ask 02_what_is_wrong_with_pod ✅
ask 04_related_k8s_events ✅
ask 05_image_version ✅
ask 09_crashpod ✅
ask 10_image_pull_backoff ✅
ask 110_k8s_events_image_pull ✅
ask 11_init_containers ✅
ask 13a_pending_node_selector_basic ✅
ask 14_pending_resources ✅
ask 15_failed_readiness_probe ✅
ask 17_oom_kill ✅
ask 18_oom_kill_from_issues_history ✅
ask 19_detect_missing_app_details ✅
ask 20_long_log_file_search ✅
ask 24_misconfigured_pvc ❌
ask 24a_misconfigured_pvc_basic ✅
ask 28_permissions_error 🚧
ask 39_failed_toolset ✅
ask 41_setup_argo ✅
ask 42_dns_issues_steps_new_tools ✅
ask 43_current_datetime_from_prompt ✅
ask 45_fetch_deployment_logs_simple ✅
ask 51_logs_summarize_errors ✅
ask 53_logs_find_term ✅
ask 54_not_truncated_when_getting_pods ✅
ask 59_label_based_counting ✅
ask 60_count_less_than ✅
ask 61_exact_match_counting ✅
ask 63_fetch_error_logs_no_errors ✅
ask 79_configmap_mount_issue ✅
ask 83_secret_not_found ✅
ask 86_configmap_like_but_secret ❌
ask 93_calling_datadog[0] ❌
ask 93_calling_datadog[1] ❌
ask 93_calling_datadog[2] ❌

Legend

  • ✅ the test was successful
  • :minus: the test was skipped
  • ⚠️ the test failed but is known to be flaky or known to fail
  • 🚧 the test had a setup failure (not a code regression)
  • 🔧 the test failed due to mock data issues (not a code regression)
  • 🚫 the test was throttled by API rate limits/overload
  • ❌ the test failed and should be fixed before merging the PR

@Avi-Robusta

Copy link
Copy Markdown
Collaborator

we updated mcp_toolset to support http-streamable and opened a seperate pr for stdio support
#1125
Closing this as its redundant now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants