Skip to content

feat: Add stdio transport support for Metals MCP server#8266

Open
majk-p wants to merge 14 commits intomainfrom
feat/stdio-mcp
Open

feat: Add stdio transport support for Metals MCP server#8266
majk-p wants to merge 14 commits intomainfrom
feat/stdio-mcp

Conversation

@majk-p
Copy link
Member

@majk-p majk-p commented Mar 17, 2026

This PR follows up on #8156 by adding stdio MCP transport

Summary by CodeRabbit

  • New Features

    • Stdio transport support for MCP servers with a stdio-based server option.
    • New MCP tools accessible over MCP: compile (file/module/full), format, test, dependency completion, symbol search/inspect, docs/usages, module listing, and Scalafix rule management.
    • File-based logging helpers to write logs to one or more files.
    • Test helper client for exercising MCP over stdio.
  • Bug Fixes

    • Improved MCP startup error handling and clearer warning when a configured port is ignored for stdio.
    • Minor diagnostics comment fixes.
  • Tests

    • Added comprehensive stdio MCP integration and compile tests.

@coderabbitai
Copy link

coderabbitai bot commented Mar 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds stdio transport for MCP, a transport-agnostic MCP tooling trait, a stdio MCP server implementation, file-based logging helpers, MCP exception/messages, stdio-aware startup wiring (useStdio flag), and comprehensive stdio integration tests.

Changes

Cohort / File(s) Summary
Minor Fixes
metals-mcp/src/main/scala/.../McpLanguageClient.scala
Comment typo fixes only; no behavior change.
Standalone MCP launcher
metals-mcp/src/main/scala/.../StandaloneMcpService.scala, metals-mcp/src/main/scala/.../McpMain.scala
Added useStdio flag, conditionalized initial config/port handling and startup path; stdio path uses file logging and warns when port provided.
MCP tools framework & servers
metals/src/main/scala/.../mcp/MetalsMcpTools.scala, metals/src/main/scala/.../mcp/MetalsMcpServer.scala, metals/src/main/scala/.../mcp/MetalsMcpStdioServer.scala
Introduced MetalsMcpTools trait centralizing tool implementations and helpers; refactored MetalsMcpServer to use the trait and HTTP transport; added MetalsMcpStdioServer for stdio transport.
Logging & exceptions
metals/src/main/scala/.../logging/MetalsLogger.scala, metals/src/main/scala/.../mcp/McpExceptions.scala
Added file-based logging helpers (configure/redirect to files) and new MCP messages/exceptions types for structured argument errors/messages.
LSP service integration
metals/src/main/scala/.../ProjectMetalsLspService.scala
Added startMcpStdioServer() and consolidated startup via startMcpServer(useStdio: Boolean); improved failure handling to reset running flag and propagate failures.
Tests & test tooling
tests/unit/src/.../TestMcpStdioClient.scala, tests/slow/src/.../McpStdioSuite.scala, tests/slow/src/.../McpStdioCompileToolsSuite.scala, project/TestGroups.scala, tests/slow/src/.../StandaloneMcpSuite.scala
Added TestMcpStdioClient test helper, two stdio test suites and registrations, and updated StandaloneMcpService test invocation to pass useStdio=false.

Sequence Diagram

sequenceDiagram
    participant Client as MCP Client
    participant StdioServer as MetalsMcpStdioServer
    participant Tools as MetalsMcpTools
    participant Engine as McpQueryEngine
    participant LSP as ProjectMetalsLspService

    Client->>StdioServer: Initialize (stdio transport)
    StdioServer->>Tools: run()
    Tools->>Tools: buildCapabilities()
    Tools->>Tools: registerAllTools(asyncServer)
    StdioServer-->>Client: ServerCapabilities

    Client->>StdioServer: CallTool (e.g., compile-file)
    StdioServer->>Tools: withErrorHandling(call)
    Tools->>Engine: perform query/compile
    Engine->>LSP: request LSP info
    LSP-->>Engine: LSP response
    Engine-->>Tools: operation result
    Tools-->>StdioServer: CallToolResult
    StdioServer-->>Client: Tool response

    Client->>StdioServer: Shutdown
    StdioServer->>Tools: cancel()
    Tools-->>Client: closed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • tgodzik

Poem

🐇 I hopped into stdio's light,
tools lined up and ready to bite,
I logged to files and ran the tests,
servers spun up, did all their quests,
carrots for CI, a hoppity night! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and specifically describes the main change: adding stdio transport support to the Metals MCP server, which is the primary objective across all modified files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stdio-mcp

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.

Tip

CodeRabbit can use OpenGrep to find security vulnerabilities and bugs across 17+ programming languages.

OpenGrep is compatible with Semgrep configurations. Add an opengrep.yml or semgrep.yml configuration file to your project to enable OpenGrep analysis.

@majk-p majk-p marked this pull request as ready for review March 18, 2026 15:50
@majk-p
Copy link
Member Author

majk-p commented Mar 18, 2026

@tgodzik looks better now and seems to pass the tests

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

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

⚠️ Outside diff range comments (1)
metals-mcp/src/main/scala/scala/meta/metals/McpMain.scala (1)

264-283: ⚠️ Potential issue | 🟡 Minor

Reject or warn on --port when --transport stdio is selected.

runServer computes useStdio and then passes config.port, but stdio mode does not use port-based transport. Silent acceptance of --port is confusing.

💡 Proposed fix
   private def runServer(config: Config): Unit = {
+    if (config.transport == Transport.Stdio && config.port.nonEmpty) {
+      System.err.println("Error: --port is only supported with --transport http")
+      sys.exit(1)
+    }
+
     val workspace = AbsolutePath(
       config.workspace.get.toNIO.toAbsolutePath().normalize()
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals-mcp/src/main/scala/scala/meta/metals/McpMain.scala` around lines 264 -
283, Detect the conflicting flags when computing useStdio (config.transport ==
Transport.Stdio): if useStdio is true and a port was provided in config
(config.port is set/non-zero), either fail fast with a clear error (throw/exit)
or log a warning and ignore the port before constructing StandaloneMcpService;
update the validation in McpMain.runServer (where useStdio and config.port are
read and where StandaloneMcpService is instantiated) to perform this check and
ensure the port value is not passed through to StandaloneMcpService when stdio
is chosen.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 76-77: Change the eager initialization of client in the
MetalsMcpTools trait to a lazy val so clientName (which is provided by the
concrete classes MetalsMcpServer and MetalsMcpStdioServer) is initialized first;
specifically replace the current protected val client =
Client.allClients.find(_.names.contains(clientName)).getOrElse(NoClient) with a
lazy val, ensuring downstream logic such as MetalsMcpServer.cancel() and its
shouldCleanUpServerEntry check uses the correctly resolved client instance.
- Around line 1214-1242: The current withErrorHandling only catches synchronous
throws during invocation of f but ignores asynchronous failures from the
returned Mono; change it to call f(exchange, arguments) inside a Mono.defer (or
keep the try for immediate exceptions) and then attach a reactive error handler
like .onErrorResume to the returned Mono to convert any Throwable into a
CallToolResult error via CallToolResult.builder().content(createContent(s"Error:
" + e.getMessage)).isError(true).build(); also remove arguments.toJson() from
all logs and instead log a short non-sensitive message (e.g., "Error while
processing request" and the exception message/stack) using scribe.warn so no raw
request payloads (arguments) are printed. Ensure you reference
withErrorHandling, f, McpAsyncServerExchange, CallToolResult, and createContent
when making the changes.
- Around line 305-339: The code for the "compile-module" tool only inspects
diagnostics and never triggers a build; to fix it, invoke the compilation API
for the found target before reading diagnostics: after matching case
Some(target) call the appropriate compilations method (e.g.,
compilations.compile or compilations.compileTargets/compileTarget with
target.id) and await its completion, then gather diagnostics via inModuleErrors,
diagnostics.allDiagnostics and upstreamModulesErros to produce the result;
update the block that builds CallToolResult (inside the Future in
withErrorHandling) to run the compilation for target.id first and only then
compute result/content based on post-compilation diagnostics.
- Around line 936-953: The current handler for languageClient.applyEdit ignores
the ApplyWorkspaceEditResponse and always returns a successful CallToolResult;
change the map/handler to inspect the ApplyWorkspaceEditResponse (use
ApplyWorkspaceEditResponse.isApplied() and getFailureReason()) returned by
languageClient.applyEdit, and if isApplied() is false return a CallToolResult
with isError(true) and content created via createContent including the failure
reason and path, otherwise return the existing success CallToolResult (content
"$path was formatted", isError(false)); update the closure where formattedText,
path, createContent and CallToolResult.builder() are used to perform this
conditional logic.

In
`@metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala`:
- Around line 284-333: startMcpServer currently catches and logs exceptions but
leaves isMcpServerRunning set to true and returns a successful Future; change
startMcpServer so that any exception from the register(...).run() path resets
isMcpServerRunning to false and allows the Future to complete with failure
(i.e., rethrow or return a failed Future) so callers see the error; specifically
update startMcpServer (and the Future recover/transform block) to call
isMcpServerRunning.set(false) on error and propagate the exception instead of
swallowing it.

In `@tests/slow/src/test/scala/tests/feature/McpStdioCompileToolsSuite.scala`:
- Around line 59-117: Each test currently only performs shutdown,
client.cleanup(), and RecursivelyDelete(workspacePath) on the success path;
change each test ("stdio-compile-file-works", "stdio-compile-module-works",
"stdio-compile-full-works") so that shutdown, client.cleanup(), and
RecursivelyDelete(workspacePath) run regardless of whether initialize or the
compile call fails by using a Future cleanup hook (e.g.,
transformWith/andThen/guarantee) or by chaining a .transformWith { case _ =>
perform shutdown and cleanup and return original result } around the
initialization/compile sequence; reference the existing symbols
client.initialize, client.compileFile / client.compileModule /
client.compileFull, client.shutdown, client.cleanup, and RecursivelyDelete to
locate and modify the code.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala`:
- Around line 50-62: The test uses a bare "java" in buildServerParameters()
which relies on PATH; instead construct an absolute JVM executable from
System.getProperty("java.home") (e.g.,
Paths.get(System.getProperty("java.home"), "bin", "java").toString()) and pass
that string to ServerParameters.builder(...) so the child process uses the same
JRE as the test JVM; update buildServerParameters() to compute javaExec and use
it in ServerParameters.builder(...) (handle platform-specific executable name if
needed).
- Around line 103-107: shutdown() currently calls client.closeGracefully() which
can throw synchronously if _client wasn't assigned (e.g., initialize() failed);
make shutdown() resilient by first checking that _client/client is
non-null/defined or wrapping the call in a try/catch so it never throws
synchronously, then return the existing Future/Promise path (convert to Scala
Future and recover NonFatal as before). Ensure you reference and guard the same
identifiers used in the file (_client or client, shutdown(), initialize(), and
client.closeGracefully()) so cleanup becomes best-effort and cannot cause a
second failure.

---

Outside diff comments:
In `@metals-mcp/src/main/scala/scala/meta/metals/McpMain.scala`:
- Around line 264-283: Detect the conflicting flags when computing useStdio
(config.transport == Transport.Stdio): if useStdio is true and a port was
provided in config (config.port is set/non-zero), either fail fast with a clear
error (throw/exit) or log a warning and ignore the port before constructing
StandaloneMcpService; update the validation in McpMain.runServer (where useStdio
and config.port are read and where StandaloneMcpService is instantiated) to
perform this check and ensure the port value is not passed through to
StandaloneMcpService when stdio is chosen.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d4149d81-4e00-48b8-b1e9-f854a6375291

📥 Commits

Reviewing files that changed from the base of the PR and between e70ead3 and 1437a4f.

📒 Files selected for processing (14)
  • metals-mcp/src/main/scala/scala/meta/internal/metals/mcp/McpLanguageClient.scala
  • metals-mcp/src/main/scala/scala/meta/internal/metals/mcp/StandaloneMcpService.scala
  • metals-mcp/src/main/scala/scala/meta/metals/McpMain.scala
  • metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala
  • metals/src/main/scala/scala/meta/internal/metals/logging/MetalsLogger.scala
  • metals/src/main/scala/scala/meta/internal/metals/mcp/McpExceptions.scala
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpServer.scala
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpStdioServer.scala
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala
  • project/TestGroups.scala
  • tests/slow/src/test/scala/tests/feature/McpStdioCompileToolsSuite.scala
  • tests/slow/src/test/scala/tests/feature/McpStdioSuite.scala
  • tests/slow/src/test/scala/tests/feature/StandaloneMcpSuite.scala
  • tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala

Copy link

@coderabbitai coderabbitai bot left a comment

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 (2)
metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala (2)

75-76: ⚠️ Potential issue | 🔴 Critical

Use lazy val to defer client initialization until after constructor completion.

The client field is initialized during trait construction, but clientName is an abstract member that concrete classes implement as a constructor parameter. In Scala 2 trait initialization, clientName remains uninitialized (null) when this line executes, causing Client.allClients.find(_.names.contains(null)) to always return NoClient.

This breaks client-specific configuration and cleanup paths in subclasses like MetalsMcpServer.

Proposed fix
-  protected val client =
+  protected lazy val client =
     Client.allClients.find(_.names.contains(clientName)).getOrElse(NoClient)

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`
around lines 75 - 76, The field `client` in `MetalsMcpTools` is being
initialized too early using `protected val client =
Client.allClients.find(_.names.contains(clientName)).getOrElse(NoClient)` while
`clientName` is abstract and not yet set; change this to `protected lazy val
client` so initialization is deferred until after constructor completion
(ensuring the lookup against `Client.allClients` uses the concrete
`clientName`), keeping the rest of the expression the same and preserving
references to `Client.allClients`, `clientName`, and `NoClient`.

304-340: ⚠️ Potential issue | 🔴 Critical

compile-module does not actually compile the module.

The tool finds the target module but only inspects existing diagnostics via inModuleErrors() and upstreamModulesErros(). Without a call to compilations.cascadeCompile() (as done in createCompileTool() at line 158-159), the tool can report "Compilation successful." even though nothing was built.

Proposed fix
     new AsyncToolSpecification(
       tool,
       withErrorHandling { (exchange, arguments) =>
         val module = arguments.getAs[String]("module")
-        Future {
-          (buildTargets.allScala ++ buildTargets.allJava).find(
-            _.displayName == module
-          ) match {
-            case Some(target) =>
-              val result = inModuleErrors(target.id)
+        (buildTargets.allScala ++ buildTargets.allJava).find(
+          _.displayName == module
+        ) match {
+          case Some(target) =>
+            compilations
+              .cascadeCompile(List(target.id))
+              .map { _ =>
+                val result = inModuleErrors(target.id)
                   .map { diagnosticsOutput =>
                     // ... existing diagnostic logic ...
                   }
                   .orElse(upstreamModulesErros(target.id, "module"))
                   .getOrElse("Compilation successful.")
-              CallToolResult
-                .builder()
-                .content(createContent(result))
-                .isError(false)
-                .build()
-            case None =>
+                CallToolResult
+                  .builder()
+                  .content(createContent(result))
+                  .isError(false)
+                  .build()
+              }
+              .toMono
+          case None =>
+            Future.successful(
               CallToolResult
                 .builder()
                 .content(createContent(s"Error: Module not found: $module"))
                 .isError(true)
                 .build()
-          }
-        }.toMono
+            ).toMono
+        }
       },
     )

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`
around lines 304 - 340, The compile-module handler currently only reads
diagnostics (inModuleErrors, upstreamModulesErros) but never triggers a build;
update the handler inside withErrorHandling so it invokes
compilations.cascadeCompile(target.id) (the same API used by createCompileTool)
and waits for its result before collecting diagnostics; then use the
post-compile diagnostics (inModuleErrors and upstreamModulesErros) to determine
the CallToolResult content and error flag instead of assuming "Compilation
successful." This change should be applied in the lambda that handles the module
lookup (the block that matches Some(target) and builds the CallToolResult).
🧹 Nitpick comments (3)
tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala (2)

269-279: Temp directory may leak if cleanup() is not called.

The tempDir is created unconditionally in the constructor (line 46), but deletion only happens if cleanup() is explicitly invoked. If tests fail or don't call cleanup(), the temp directory accumulates.

Consider either:

  1. Making tempDir lazy so it's only created when actually needed, or
  2. Adding a finalizer/try-with-resources pattern for the test client lifecycle

This is a minor concern for test code but worth noting for CI hygiene.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala` around lines
269 - 279, The test client currently creates tempDir eagerly in the constructor
(tempDir) and only deletes it when cleanup() is explicitly called, risking
leaked temp dirs; change the lifecycle so the temp dir is created lazily or is
always cleaned up automatically: either make tempDir a lazy val (so it is only
created on first use) or implement AutoCloseable/close on the test client and
call cleanup() from close(), then use scala.util.Using or try-with-resources in
tests to ensure cleanup() is invoked; update references to tempDir, the
constructor, and the cleanup() method accordingly so tests cannot leave the temp
directory behind.

72-80: Potential resource leak if createFreshClient() is called multiple times.

If createFreshClient() is invoked more than once (e.g., for retry logic or test reset), the previous _transport and _client are overwritten without being closed, potentially leaking subprocess handles and transport resources.

Consider closing existing resources before creating new ones:

Proposed fix
  private def createFreshClient(): McpAsyncClient = {
+   // Close existing resources if any
+   Option(_client).foreach { c =>
+     try { c.closeGracefully().toFuture().get() } catch { case NonFatal(_) => }
+   }
    val params = buildServerParameters()
    _transport = new StdioClientTransport(params, jsonMapper)
    _client = McpClient
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala` around lines 72
- 80, createFreshClient currently overwrites _transport and _client and can leak
resources; before creating new ones in createFreshClient(), check if existing
_client and _transport are non-null and close or shut them down (call their
close()/shutdown()/stop() methods as appropriate) and swallow/log any
exceptions, then proceed to instantiate new StdioClientTransport(params,
jsonMapper) and McpClient.async(...). Ensure you reference and close the
previous _client and _transport fields before assigning new instances created by
StdioClientTransport and McpClient.async(...).
metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala (1)

1195-1214: Fire-and-forget .subscribe() calls may silently drop tool registration errors.

Each addTool(...).subscribe() call subscribes to the Mono without any error handling. If tool registration fails (e.g., due to schema validation errors), the exception is silently swallowed.

Consider adding error logging:

Proposed fix
  protected def registerAllTools(
      asyncServer: io.modelcontextprotocol.server.McpAsyncServer
  ): Unit = {
-    asyncServer.addTool(createFileCompileTool()).subscribe()
-    asyncServer.addTool(createCompileModuleTool()).subscribe()
+    def registerTool(tool: AsyncToolSpecification): Unit =
+      asyncServer.addTool(tool).subscribe(
+        _ => (),
+        err => scribe.error(s"Failed to register tool: ${err.getMessage}", err)
+      )
+    registerTool(createFileCompileTool())
+    registerTool(createCompileModuleTool())
     // ... remaining tools ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`
around lines 1195 - 1214, The registerAllTools method currently calls
asyncServer.addTool(...).subscribe() for each tool which will silently swallow
errors; wrap each subscription with error handling (or aggregate the Monos and
subscribe once) so failures are logged and surfaced: for each call to
asyncServer.addTool(...) in registerAllTools, attach onError logging (e.g.,
doOnError or subscribe(onSuccess, onError)) that logs the tool name and
exception via the Metals logger, or collect all returned Monos from
createFileCompileTool/createCompileModuleTool/createCompileTool/.../createListScalafixRulesTool
and use a combined reactive operator (e.g., Mono.when or Flux.mergeSequential)
with a single subscribe that logs errors; ensure you reference the tool creation
methods (createFileCompileTool, createCompileModuleTool, createCompileTool,
createTestTool, createGlobSearchTool, createTypedGlobSearchTool,
createInspectTool, createGetDocsTool, createGetUsagesTool, importBuildTool,
createFindDepTool, createListModulesTool, createFormatTool,
createGenerateScalafixRuleTool, createRunScalafixRuleTool,
createListScalafixRulesTool) when adding the error handling so failures are not
silently dropped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 1282-1292: The getOptAs method currently throws
IncorrectArgumentTypeException on a type mismatch (via .orElse(throw ...)) which
contradicts its Option-returning contract; change getOptAs (not getAs) to return
None for any cast failure by removing the thrown exception and returning
Try(value.asInstanceOf[T]).toOption (or otherwise catching ClassCastException
and returning None) so callers get None on invalid types; if throwing behavior
was intentional, instead rename getOptAs to something like getOptAsOrThrow and
leave the exception logic in place.

---

Duplicate comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 75-76: The field `client` in `MetalsMcpTools` is being initialized
too early using `protected val client =
Client.allClients.find(_.names.contains(clientName)).getOrElse(NoClient)` while
`clientName` is abstract and not yet set; change this to `protected lazy val
client` so initialization is deferred until after constructor completion
(ensuring the lookup against `Client.allClients` uses the concrete
`clientName`), keeping the rest of the expression the same and preserving
references to `Client.allClients`, `clientName`, and `NoClient`.
- Around line 304-340: The compile-module handler currently only reads
diagnostics (inModuleErrors, upstreamModulesErros) but never triggers a build;
update the handler inside withErrorHandling so it invokes
compilations.cascadeCompile(target.id) (the same API used by createCompileTool)
and waits for its result before collecting diagnostics; then use the
post-compile diagnostics (inModuleErrors and upstreamModulesErros) to determine
the CallToolResult content and error flag instead of assuming "Compilation
successful." This change should be applied in the lambda that handles the module
lookup (the block that matches Some(target) and builds the CallToolResult).

---

Nitpick comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 1195-1214: The registerAllTools method currently calls
asyncServer.addTool(...).subscribe() for each tool which will silently swallow
errors; wrap each subscription with error handling (or aggregate the Monos and
subscribe once) so failures are logged and surfaced: for each call to
asyncServer.addTool(...) in registerAllTools, attach onError logging (e.g.,
doOnError or subscribe(onSuccess, onError)) that logs the tool name and
exception via the Metals logger, or collect all returned Monos from
createFileCompileTool/createCompileModuleTool/createCompileTool/.../createListScalafixRulesTool
and use a combined reactive operator (e.g., Mono.when or Flux.mergeSequential)
with a single subscribe that logs errors; ensure you reference the tool creation
methods (createFileCompileTool, createCompileModuleTool, createCompileTool,
createTestTool, createGlobSearchTool, createTypedGlobSearchTool,
createInspectTool, createGetDocsTool, createGetUsagesTool, importBuildTool,
createFindDepTool, createListModulesTool, createFormatTool,
createGenerateScalafixRuleTool, createRunScalafixRuleTool,
createListScalafixRulesTool) when adding the error handling so failures are not
silently dropped.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala`:
- Around line 269-279: The test client currently creates tempDir eagerly in the
constructor (tempDir) and only deletes it when cleanup() is explicitly called,
risking leaked temp dirs; change the lifecycle so the temp dir is created lazily
or is always cleaned up automatically: either make tempDir a lazy val (so it is
only created on first use) or implement AutoCloseable/close on the test client
and call cleanup() from close(), then use scala.util.Using or try-with-resources
in tests to ensure cleanup() is invoked; update references to tempDir, the
constructor, and the cleanup() method accordingly so tests cannot leave the temp
directory behind.
- Around line 72-80: createFreshClient currently overwrites _transport and
_client and can leak resources; before creating new ones in createFreshClient(),
check if existing _client and _transport are non-null and close or shut them
down (call their close()/shutdown()/stop() methods as appropriate) and
swallow/log any exceptions, then proceed to instantiate new
StdioClientTransport(params, jsonMapper) and McpClient.async(...). Ensure you
reference and close the previous _client and _transport fields before assigning
new instances created by StdioClientTransport and McpClient.async(...).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a5189320-9134-4705-a918-00684ad8c40a

📥 Commits

Reviewing files that changed from the base of the PR and between 1437a4f and 12418f0.

📒 Files selected for processing (5)
  • metals-mcp/src/main/scala/scala/meta/metals/McpMain.scala
  • metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala
  • tests/slow/src/test/scala/tests/feature/McpStdioCompileToolsSuite.scala
  • tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/slow/src/test/scala/tests/feature/McpStdioCompileToolsSuite.scala
  • metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala (1)

304-338: ⚠️ Potential issue | 🔴 Critical

compile-module still skips the actual compile.

This branch still only reads cached diagnostics, so it can return Compilation successful. for a dirty target that has not been rebuilt yet.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`
around lines 304 - 338, The handler for the "compile-module" branch currently
only reads cached diagnostics (using inModuleErrors and upstreamModulesErros)
and can wrongly return "Compilation successful." for a dirty target; instead,
before composing the CallToolResult (the block that builds content via
CallToolResult.builder().content(createContent(...)).isError(...)), invoke the
project's compile/build API for the found target (use target.id) and await the
compile result, then use that compile outcome to decide isError and the message;
after the compile completes, read inModuleErrors(target.id) and
upstreamModulesErros(target.id, "module") to include real diagnostics if
compilation failed or produced warnings. Ensure you replace the current
short-circuit that only checks cached diagnostics with this compile invocation
and subsequent diagnostic aggregation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 867-877: The current list-modules implementation filters
buildTargets.allBuildTargetIds through buildTargets.jvmTarget, which drops
non-JVM targets; change it to include all relevant module IDs (e.g., use
buildTargets.allScala ++ buildTargets.allJava or map allBuildTargetIds to their
displayName without calling buildTargets.jvmTarget) so the module list matches
what compile-module accepts. Update the code that builds modules (replace the
flatMap(buildTargets.jvmTarget(_).map(_.displayName)) usage) to collect
displayName for all applicable build targets and keep the
CallToolResult.builder().content(...) construction the same.
- Around line 1288-1299: The getAsList[T] method currently silently drops
non-matching elements in the JList[_] branch by using collect { case value: T =>
value }, which allows mixed-type arrays like ["class", 1] to become partial
results; update the JList[_] handling so it validates every element's runtime
type against T (using the provided ClassTag or pattern matching) and if any
element does not match, throw a clear argument error (e.g.,
IllegalArgumentException or a MissingArgumentException with the key) instead of
filtering; ensure the exception includes the key and offending value(s) to aid
debugging and keep behavior consistent with the String/json branch that fails on
invalid input.

In
`@metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala`:
- Around line 287-328: The two MCP server constructors are being passed an extra
callback layer by wrapping focusedDocument with `() =>`, producing `() => (() =>
Option[AbsolutePath])`; pass the existing focusedDocument value directly where
the constructors expect a `() => Option[AbsolutePath] — update the arguments to
MetalsMcpStdioServer and MetalsMcpServer (the parameter currently wrapped) to
use focusedDocument without the extra `() =>` wrapper at both call sites.

---

Duplicate comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 304-338: The handler for the "compile-module" branch currently
only reads cached diagnostics (using inModuleErrors and upstreamModulesErros)
and can wrongly return "Compilation successful." for a dirty target; instead,
before composing the CallToolResult (the block that builds content via
CallToolResult.builder().content(createContent(...)).isError(...)), invoke the
project's compile/build API for the found target (use target.id) and await the
compile result, then use that compile outcome to decide isError and the message;
after the compile completes, read inModuleErrors(target.id) and
upstreamModulesErros(target.id, "module") to include real diagnostics if
compilation failed or produced warnings. Ensure you replace the current
short-circuit that only checks cached diagnostics with this compile invocation
and subsequent diagnostic aggregation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b11b10a-c7c2-466a-b450-cc69ec0ab207

📥 Commits

Reviewing files that changed from the base of the PR and between 12418f0 and 3c0ed4a.

📒 Files selected for processing (2)
  • metals/src/main/scala/scala/meta/internal/metals/ProjectMetalsLspService.scala
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala (2)

1290-1310: ⚠️ Potential issue | 🟡 Minor

getAsList silently drops non-matching array elements.

The JList[_] branch at line 1299-1300 uses collect { case value: T => value }, which silently filters out elements that don't match type T. A mixed-type payload like ["class", 1] would become List("class") instead of raising an error, potentially masking input mistakes.

Proposed fix to validate all elements
           case value: JList[_] =>
-            value.asScala.collect { case value: T => value }.toList
+            val converted = value.asScala.map {
+              case v: T => v
+              case other =>
+                throw new IncorrectArgumentTypeException(
+                  key,
+                  s"expected ${ct.runtimeClass.getSimpleName}, got ${other.getClass.getSimpleName}",
+                )
+            }.toList
+            converted
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`
around lines 1290 - 1310, The getAsList[T] method currently silently drops
non-matching elements in the JList branch by using collect { case value: T =>
value }, which masks mixed-type inputs; update the JList[_] branch in getAsList
to validate every element is an instance of T (using the implicit ClassTag
ct/runtimeClass) and if any element fails, throw
IncorrectArgumentTypeException(key, s"Array[${ct.runtimeClass.getSimpleName}]"),
otherwise return the full list of elements cast to T; ensure the
MissingArgumentException handling remains unchanged.

868-883: ⚠️ Potential issue | 🟡 Minor

list-modules filters to JVM targets only, but compile-module accepts all Scala/Java targets.

The list-modules tool uses buildTargets.jvmTarget(_).map(_.displayName) which excludes Scala.js and Scala Native targets. However, compile-module (lines 307-308) searches (buildTargets.allScala ++ buildTargets.allJava). This inconsistency means users can't discover non-JVM modules via list-modules even though they can compile them.

Proposed fix to align with compile-module
       withErrorHandling { (_, _) =>
         Future {
           val modules =
-            buildTargets.allBuildTargetIds.flatMap(
-              buildTargets.jvmTarget(_).map(_.displayName)
-            )
+            (buildTargets.allScala ++ buildTargets.allJava)
+              .map(_.displayName)
+              .distinct
           CallToolResult
             .builder()
             .content(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`
around lines 868 - 883, The list-modules tool currently collects modules using
buildTargets.jvmTarget(...).map(_.displayName), which excludes Scala.js/Native
targets; change it to mirror compile-module's selection by collecting
(buildTargets.allScala ++ buildTargets.allJava).map(_.displayName) (or the
equivalent method used by compile-module) and use those display names when
building the CallToolResult content so list-modules shows the same set of
compile-able modules as compile-module.
🧹 Nitpick comments (2)
tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala (1)

47-47: Unused tempDir field.

The tempDir is created at construction but is never used by the client (the workspacePath parameter is passed to the server separately). The cleanup() method deletes it, but since nothing writes to it, this is wasted I/O.

Consider removing both the field and the cleanup() method, or using tempDir as the actual workspace for tests that need an isolated directory.

Proposed fix to remove unused tempDir
-  private val tempDir = Files.createTempDirectory("metals-mcp-stdio-test")
-
   private var _client: McpAsyncClient = _
   private var _transport: StdioClientTransport = _

And remove the cleanup method:

-  /** Clean up temp directory */
-  def cleanup(): Unit = {
-    try {
-      Files
-        .walk(tempDir)
-        .sorted(Comparator.reverseOrder[Path]())
-        .forEach(Files.delete)
-    } catch {
-      case NonFatal(_) =>
-    }
-  }

Also applies to: 270-280

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala` at line 47, The
tempDir field in TestMcpStdioClient is created but never used and only deleted
in cleanup(); remove the unused private val tempDir and the cleanup() method, or
alternatively repurpose tempDir by passing tempDir to the server as the
workspace (replace usages of workspacePath with tempDir where isolation is
required) so the directory is actually used; update any tests that relied on
cleanup() accordingly and remove related deletion logic to avoid redundant I/O.
metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala (1)

358-373: Typo in method name: upstreamModulesErrosupstreamModulesErrors.

This method is called from multiple tools (createFileCompileTool at line 254, createCompileModuleTool at line 324). Consider fixing the typo for readability.

Proposed fix
-  protected def upstreamModulesErros(
+  protected def upstreamModulesErrors(
       buildTarget: BuildTargetIdentifier,
       fileOrModule: String,
   ): Option[String] = {

Also update call sites at lines 254 and 324.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`
around lines 358 - 373, Rename the typo'd method upstreamModulesErros to
upstreamModulesErrors in MetalsMcpTools (method signature and all internal
references), and update every call site that uses upstreamModulesErros—notably
the invocations inside createFileCompileTool and createCompileModuleTool—to call
upstreamModulesErrors instead; ensure the method visibility and behavior remain
unchanged apart from the name so callers compile cleanly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 1290-1310: The getAsList[T] method currently silently drops
non-matching elements in the JList branch by using collect { case value: T =>
value }, which masks mixed-type inputs; update the JList[_] branch in getAsList
to validate every element is an instance of T (using the implicit ClassTag
ct/runtimeClass) and if any element fails, throw
IncorrectArgumentTypeException(key, s"Array[${ct.runtimeClass.getSimpleName}]"),
otherwise return the full list of elements cast to T; ensure the
MissingArgumentException handling remains unchanged.
- Around line 868-883: The list-modules tool currently collects modules using
buildTargets.jvmTarget(...).map(_.displayName), which excludes Scala.js/Native
targets; change it to mirror compile-module's selection by collecting
(buildTargets.allScala ++ buildTargets.allJava).map(_.displayName) (or the
equivalent method used by compile-module) and use those display names when
building the CallToolResult content so list-modules shows the same set of
compile-able modules as compile-module.

---

Nitpick comments:
In `@metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala`:
- Around line 358-373: Rename the typo'd method upstreamModulesErros to
upstreamModulesErrors in MetalsMcpTools (method signature and all internal
references), and update every call site that uses upstreamModulesErros—notably
the invocations inside createFileCompileTool and createCompileModuleTool—to call
upstreamModulesErrors instead; ensure the method visibility and behavior remain
unchanged apart from the name so callers compile cleanly.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala`:
- Line 47: The tempDir field in TestMcpStdioClient is created but never used and
only deleted in cleanup(); remove the unused private val tempDir and the
cleanup() method, or alternatively repurpose tempDir by passing tempDir to the
server as the workspace (replace usages of workspacePath with tempDir where
isolation is required) so the directory is actually used; update any tests that
relied on cleanup() accordingly and remove related deletion logic to avoid
redundant I/O.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e56504e6-8dde-478b-9536-c1f4107b2d07

📥 Commits

Reviewing files that changed from the base of the PR and between 3c0ed4a and 20e1419.

📒 Files selected for processing (3)
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpStdioServer.scala
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpTools.scala
  • tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala
✅ Files skipped from review due to trivial changes (1)
  • metals/src/main/scala/scala/meta/internal/metals/mcp/MetalsMcpStdioServer.scala

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala (2)

46-47: Either use tempDir for isolation or drop it.

Each client allocates a temp directory here, but the subprocess is still started against workspacePath and nothing else consumes tempDir except cleanup. If this directory is meant to back stdio logs or scratch state, thread it into the server setup; otherwise removing it would simplify the lifecycle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala` around lines 46
- 47, TestMcpStdioClient defines a tempDir (private val tempDir) that is never
used; either remove the tempDir declaration and its cleanup to simplify
lifecycle, or thread it into the server/client setup (e.g., pass tempDir as the
workspace/log/scratch path when constructing the MCP stdio server or client in
TestMcpStdioClient) and ensure the test uses that directory for subprocess
workingDir or log output and still deletes it in teardown; update any setup
methods that create the subprocess to accept and use tempDir.

76-95: Avoid making callTool text-only at the transport boundary.

This helper strips the SDK response down to List[String] by collecting only TextContent. The MCP specification and Java SDK support tool failures via isError metadata in CallToolResult, non-text payloads (image, audio, embedded resources), and structuredContent for structured results. The current implementation discards all of this: callers cannot inspect failure indicators, non-text responses, or structured output. Returning the full CallToolResult or failing explicitly when isError is true would preserve test visibility into the full range of tool responses.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala` around lines 76
- 95, The helper callTool currently converts the SDK CallToolResult into a
List[String] by collecting only TextContent, which drops isError metadata,
structuredContent and non-text payloads; change callTool to return the full
CallToolResult (or fail when result.isError) instead of List[String] so tests
can inspect errors and non-text/structured responses—update the method signature
of callTool, stop mapping client.callTool(...).toFuture().toScala to extract
TextContent, and return the original CallToolResult produced by client.callTool
(references: callTool, CallToolRequest, client.callTool, CallToolResult,
TextContent).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala`:
- Around line 255-259: Files.walk(tempDir) returns a Stream[Path] that must be
closed; change the deletion block to open the stream into a local variable and
ensure it is closed (e.g., acquire the stream from Files.walk(tempDir), call
stream.sorted(Comparator.reverseOrder[Path]()).forEach(Files.delete) inside a
try/finally and call stream.close() in finally or use a try-with-resources
equivalent) so the stream opened by Files.walk is closed before attempting to
delete the temp tree.

---

Nitpick comments:
In `@tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala`:
- Around line 46-47: TestMcpStdioClient defines a tempDir (private val tempDir)
that is never used; either remove the tempDir declaration and its cleanup to
simplify lifecycle, or thread it into the server/client setup (e.g., pass
tempDir as the workspace/log/scratch path when constructing the MCP stdio server
or client in TestMcpStdioClient) and ensure the test uses that directory for
subprocess workingDir or log output and still deletes it in teardown; update any
setup methods that create the subprocess to accept and use tempDir.
- Around line 76-95: The helper callTool currently converts the SDK
CallToolResult into a List[String] by collecting only TextContent, which drops
isError metadata, structuredContent and non-text payloads; change callTool to
return the full CallToolResult (or fail when result.isError) instead of
List[String] so tests can inspect errors and non-text/structured
responses—update the method signature of callTool, stop mapping
client.callTool(...).toFuture().toScala to extract TextContent, and return the
original CallToolResult produced by client.callTool (references: callTool,
CallToolRequest, client.callTool, CallToolResult, TextContent).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 40576815-f437-40b1-af68-6c644057dc44

📥 Commits

Reviewing files that changed from the base of the PR and between 20e1419 and d0b0edd.

📒 Files selected for processing (1)
  • tests/unit/src/main/scala/tests/mcp/TestMcpStdioClient.scala

Copy link
Contributor

@tgodzik tgodzik left a comment

Choose a reason for hiding this comment

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

Great work! I have some further comments, but nothing blocking. This already looks very good.


def configureFileLogging(logfile: AbsolutePath): Unit = {
Files.createDirectories(logfile.toNIO.getParent)
configureFileLogging(List(logfile))
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
configureFileLogging(List(logfile))
redirectSystemOut(logfile)

wouldn't be enough? Looks like the other added method has duplicated code from other parts, unless that's on purpose?

Copy link
Member Author

Choose a reason for hiding this comment

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

Problem is redirectSystemOut redirects the entire output, where StdioServerTransportProvider actually talks over System.out. This would break stdio mcp. I tried working around it such that redirectSystemOut redirects current stdout but hands over a copy we could still reuse with StdioServerTransportProvider. Unfortunately mcp sdk doesn't have a constructor that would allow passing custom output stream to StdioServerTransportProvider. If I missed something let me know, otherwise we'll have to live with this approach

_ <- client.shutdown().recover { case NonFatal(_) => () }
_ = client.cleanup()
} yield {
try RecursivelyDelete(workspacePath)
Copy link
Contributor

Choose a reason for hiding this comment

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

The code here seems duplicated, any chance to reuse the existing utilities we have?

import tests.mcp.TestMcpStdioClient

class McpStdioCompileToolsSuite
extends BaseLspSuite("mcp-stdio-compile-tools") {
Copy link
Contributor

Choose a reason for hiding this comment

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

Might not make sense to use BaseLspSuite here as we are not actually testing LSP

import tests.SbtBuildLayout
import tests.mcp.TestMcpStdioClient

class McpStdioSuite extends BaseLspSuite("mcp-stdio-suite") {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, might not make sense to use BaseLspSuite here as we are not actually testing LSP

private val objectMapper = new ObjectMapper()
private val jsonMapper = new JacksonMcpJsonMapper(objectMapper)

private val tempDir = Files.createTempDirectory("metals-mcp-stdio-test")
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this used anywhere?

case NonFatal(_) => ()
}

def listModules(): Future[String] = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we join this with TestMcpClient, some methods are duplicated between both

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.

2 participants