Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class McpLanguageClient(workspace: AbsolutePath) extends MetalsLanguageClient {
override def publishDiagnostics(
diagnostics: PublishDiagnosticsParams
): Unit = {
// diagnotics are server via the compile request
// diagnostics are served via the compile request
}

override def showMessage(message: MessageParams): Unit = {
Expand Down Expand Up @@ -173,7 +173,7 @@ class McpLanguageClient(workspace: AbsolutePath) extends MetalsLanguageClient {
}
}

// Execute client command - no-op,
// Execute client command - no-op
override def metalsExecuteClientCommand(
params: ExecuteCommandParams
): Unit = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,32 @@ import scala.meta.io.AbsolutePath
*
* @param workspace The workspace root path
* @param port Optional port for HTTP transport
* @param useStdio Whether to use stdio transport instead of HTTP
* @param scheduledExecutor Scheduled executor for background tasks
* @param client Client to generate config for (defaults to NoClient)
* @param initialUserConfig Optional user configuration (e.g. from CLI); defaults to [[UserConfiguration.default]] if None
*/
class StandaloneMcpService(
workspace: AbsolutePath,
port: Option[Int],
useStdio: Boolean,
scheduledExecutor: ScheduledExecutorService,
client: Client = NoClient,
initialUserConfig: Option[UserConfiguration] = None,
)(implicit ec: ExecutionContextExecutorService)
extends Cancelable {
port match {
case Some(port) =>
McpConfig.writeConfig(
port,
workspace.filename,
workspace,
NoClient,
Set.empty,
)
case None => // random port will be assigned by the system
if (!useStdio) {
port match {
case Some(port) =>
McpConfig.writeConfig(
port,
workspace.filename,
workspace,
NoClient,
Set.empty,
)
case None => // random port will be assigned by the system
}
}
initialUserConfig.foreach(uc =>
scribe.info(s"User configuration to use in MCP server: \n$uc")
Expand Down Expand Up @@ -139,21 +143,27 @@ class StandaloneMcpService(
def start(): Unit = {
scribe.info("Starting MCP server...")
Await.result(projectMetalsLspService.initialized(), 10.minutes)
Await.result(projectMetalsLspService.startMcpServer(), 2.minutes)
if (useStdio) {
Await.result(projectMetalsLspService.startMcpStdioServer(), 2.minutes)
} else {
Await.result(projectMetalsLspService.startMcpServer(), 2.minutes)
}
cancelables.add(projectMetalsLspService)
val createdPort =
McpConfig.readPort(workspace, workspace.filename, NoClient)
(createdPort, client) match {
case (_, NoClient) => // no client was set, nothing to do
case (Some(port), client) =>
McpConfig.writeConfig(
port,
workspace.filename,
workspace,
client,
Set.empty,
)
case (None, _) => scribe.error("No port was created")
if (!useStdio) {
val createdPort =
McpConfig.readPort(workspace, workspace.filename, NoClient)
(createdPort, client) match {
case (_, NoClient) => // no client was set, nothing to do
case (Some(port), client) =>
McpConfig.writeConfig(
port,
workspace.filename,
workspace,
client,
Set.empty,
)
case (None, _) => scribe.error("No port was created")
}
}
scribe.info("MCP server started successfully")
}
Expand Down
38 changes: 25 additions & 13 deletions metals-mcp/src/main/scala/scala/meta/metals/McpMain.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ object McpMain {
case object Http extends Transport
case object Stdio extends Transport

def fromString(s: String): Option[Transport] = s.toLowerCase match {
case "http" => Some(Http)
case "stdio" => Some(Stdio)
case _ => None
}
def fromString(s: String): Option[Transport] =
s.toLowerCase match {
case "http" => Some(Http)
case "stdio" => Some(Stdio)
case _ => None
}
}

case class Config(
Expand All @@ -57,11 +58,12 @@ object McpMain {
Client.allClients.flatMap(_.names).mkString(", ")

// Skipped config keys that do not make sense in MCP context
private def skippedConfigKeys(key: String): Boolean = key match {
case "start-mcp-server" => true
case option if option.startsWith("inlay-hints.") => true
case _ => false
}
private def skippedConfigKeys(key: String): Boolean =
key match {
case "start-mcp-server" => true
case option if option.startsWith("inlay-hints.") => true
case _ => false
}

/** Set of all config keys (kebab-case) for direct CLI parsing (e.g. --key value). */
private val configKeys: Set[String] =
Expand Down Expand Up @@ -214,7 +216,7 @@ object McpMain {
|Options:
| --workspace <path> Path to the Scala project (required)
| --port <number> HTTP port to listen on (default: auto-assign)
| --transport <type> Transport type: http (default) or stdio (reserved for future use)
| --transport <type> Transport type: http (default) or stdio
| --client <name> Client to generate config for: $validClients
| --<key> [value] UserConfiguration override. Use kebab-case (e.g. --java-home /path, --bloop-version 1.4.0).
| For boolean options, value is optional: omit for true, or use --key true/false.
Expand Down Expand Up @@ -259,13 +261,22 @@ object McpMain {
}
}

val useStdio = config.transport == Transport.Stdio

// Warn about conflicting options
if (useStdio && config.port.isDefined) {
scribe.warn(
"--port is ignored when using stdio transport. Port option is only applicable to HTTP transport."
)
}

val exec = Executors.newCachedThreadPool()
implicit val ec: ExecutionContextExecutorService =
ExecutionContext.fromExecutorService(exec)
val sh = Executors.newSingleThreadScheduledExecutor()
if (config.transport == Transport.Stdio) {
if (useStdio) {
val metalsLog = workspace.resolve(".metals/metals.log")
MetalsLogger.redirectSystemOut(metalsLog)
MetalsLogger.configureFileLogging(metalsLog)
}

scribe.info(
Expand All @@ -276,6 +287,7 @@ object McpMain {
val service = new StandaloneMcpService(
workspace,
config.port,
useStdio,
sh,
config.client,
userConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import scala.meta.internal.metals.mcp.McpQueryEngine
import scala.meta.internal.metals.mcp.McpSymbolSearch
import scala.meta.internal.metals.mcp.McpTestRunner
import scala.meta.internal.metals.mcp.MetalsMcpServer
import scala.meta.internal.metals.mcp.MetalsMcpStdioServer
import scala.meta.internal.metals.mcp.ScalafixLlmRuleProvider
import scala.meta.internal.metals.watcher.FileWatcherEvent
import scala.meta.internal.metals.watcher.FileWatcherEvent.EventType
Expand Down Expand Up @@ -275,30 +276,64 @@ class ProjectMetalsLspService(
)

def startMcpServer(): Future[Unit] =
startMcpServer(useStdio = false)

def startMcpStdioServer(): Future[Unit] =
startMcpServer(useStdio = true)

private def startMcpServer(useStdio: Boolean): Future[Unit] =
Future {
if (!isMcpServerRunning.getAndSet(true))
register(
new MetalsMcpServer(
queryEngine,
folder,
compilations,
() => focusedDocument,
diagnostics,
buildTargets,
mcpTestRunner,
userConfig.mcpClient.getOrElse(
initializeParams.getClientInfo().getName()
),
getVisibleName,
languageClient,
connectionProvider,
scalaVersionSelector,
formattingProvider,
scalafixLlmRuleProvider,
)
).run()
}.recover { case e: Exception =>
scribe.error("Error starting MCP server", e)
if (!isMcpServerRunning.getAndSet(true)) {
if (useStdio) {
register(
new MetalsMcpStdioServer(
queryEngine,
folder,
compilations,
() => focusedDocument,
diagnostics,
buildTargets,
mcpTestRunner,
userConfig.mcpClient.getOrElse(
initializeParams.getClientInfo().getName()
),
getVisibleName,
languageClient,
connectionProvider,
scalaVersionSelector,
formattingProvider,
scalafixLlmRuleProvider,
)
).run()
} else {
register(
new MetalsMcpServer(
queryEngine,
folder,
compilations,
() => focusedDocument,
diagnostics,
buildTargets,
mcpTestRunner,
userConfig.mcpClient.getOrElse(
initializeParams.getClientInfo().getName()
),
getVisibleName,
languageClient,
connectionProvider,
scalaVersionSelector,
formattingProvider,
scalafixLlmRuleProvider,
)
).run()
}
}
}.transform { result =>
result.recover { case e: Exception =>
isMcpServerRunning.set(false)
scribe.error("Error starting MCP server", e)
}
result
}

override def didChange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,43 @@ object MetalsLogger {
configureRootLogger(logfiles)
}

def redirectLogsToFile(logfile: AbsolutePath): Unit = redirectLogsToFile(
List(logfile)
)

def redirectLogsToFile(logfiles: List[AbsolutePath]): Unit = {
logfiles.foreach(logfile =>
Files.createDirectories(logfile.toNIO.getParent)
)
configureRootLogger(logfiles)
}

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

def configureFileLogging(logfiles: List[AbsolutePath]): Unit = {
logfiles.foreach(logfile =>
Files.createDirectories(logfile.toNIO.getParent)
)
logfiles
.foldLeft(
Logger.root
.clearModifiers()
.clearHandlers()
) { (logger, logfile) =>
logger
.withHandler(
writer = newFileWriter(logfile),
formatter = defaultFormat,
minimumLevel = Some(level),
modifiers = List(MetalsFilter()),
)
}
.replace()
}

private def configureRootLogger(logfile: List[AbsolutePath]): Unit = {
logfile
.foldLeft(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package scala.meta.internal.metals.mcp

object McpMessages {

object FindDep {

def versionMessage(completed: Option[String]): String = {
s"""|Latest version found: ${completed.getOrElse("none")}
|""".stripMargin
}

def dependencyReturnMessage(
key: String,
completed: Seq[String],
): String = {
s"""|Tool managed to complete `$key` field and got potential values to use for it: ${completed.mkString(", ")}
|""".stripMargin
}

def noCompletionsFound: String =
"No completions found"
}
}

sealed trait IncorrectArgumentException extends Exception

class MissingArgumentException(key: String)
extends Exception(s"Missing argument: $key")
with IncorrectArgumentException

class IncorrectArgumentTypeException(key: String, expected: String)
extends Exception(s"Incorrect argument type for $key, expected: $expected")
with IncorrectArgumentException

class InvalidSymbolTypeException(invalid: Seq[String], validTypes: String)
extends Exception(
s"Invalid symbol types: ${invalid.mkString(", ")}. Valid types are: $validTypes"
)
with IncorrectArgumentException

object MissingFileInFocusException
extends Exception(s"Missing fileInFocus and failed to infer it.")
with IncorrectArgumentException
Loading
Loading