Metals is the language server behind Scala support in VS Code, Neovim, Emacs, Sublime Text, Helix, Zed and other editors. Most developers install the extension, click "Import build", and carry on until something goes wrong: completions that know nothing about a new dependency, find references that misses half the call sites, an import that runs for ten minutes, or a red squiggle that sbt on the command line does not reproduce.

Each of those symptoms comes from a specific part of Metals. This article takes it apart: the editor client, the Metals server, the build server it talks to, the presentation compiler that answers as you type, and the SemanticDB and source index behind navigation. With that model you can tell which part is misbehaving and fix it in minutes instead of reinstalling the extension. It ends with a worked example on a large sbt monorepo, a failure table and a checklist.

Advertisement

The moving parts

Metals sits between two protocols. The editor speaks the Language Server Protocol (LSP): JSON-RPC messages such as "completion at line 40, column 12" or "find references of this symbol". Metals does not compile your project itself; it speaks the Build Server Protocol (BSP) to a build server that knows your modules, dependencies and compiler options, and that compiles on request.

Inside Metals are three knowledge sources. The presentation compiler is an interactive mode of the Scala compiler that type-checks the file you are editing, including unsaved text, and answers completions, hover and signature help. SemanticDB files are written by a compiler plugin during a real compile and record every symbol occurrence in every compiled file; they power find references, rename and workspace-wide navigation. The source index is Metals' own fast parse of your sources and dependency source jars, so go to definition works into a library without compiling it.

Metals is a broker: the editor talks LSP to it, and it talks BSP to a build serverEditor clientVS Code, Neovim, EmacsMetals serverJVM process, JDK 11+LSP (JSON-RPC)Build serverBloop, sbt, Mill, Scala CLIBSPCompiled outputclasses + SemanticDBcompilePresentation compilerone per Scala versionopen buffersSource indexworkspace + dependency sourcesindexes.metals/ directorydatabase, logsreads SemanticDBCompletions and hover come from the presentation compiler on unsaved text; find references and rename come fromSemanticDB written by the last successful compile; go to definition into libraries uses the source index.
The editor asks Metals; Metals answers from the presentation compiler for the open buffer, from SemanticDB for compiled code, and from its source index for definitions, and asks the build server to compile.

Almost every Metals complaint maps to one of these. Stale references mean SemanticDB is out of date because a compile failed. Wrong completions mean the presentation compiler has the wrong classpath or Scala version. A slow import is the build server exporting the build. Knowing which is which is most of the skill.

What Import build actually does

When you open a folder containing build.sbt, Metals offers to import it. With the default Bloop build server it writes project/metals.sbt to add the sbt-bloop plugin, starts sbt, and runs the Bloop export, which writes one JSON file per module into .bloop/: source directories, the full resolved classpath, scalac options, the Scala version and the Java home. It asks for source jars of every dependency so navigation can open library code.

Metals then connects to the Bloop server over BSP, reads the module list, and asks Bloop to compile. Bloop runs the compiler with the SemanticDB plugin enabled so that navigation data is produced as a side effect. Meanwhile Metals indexes workspace sources and dependency source jars into its database under .metals/.

The expensive steps are dependency resolution and the first full compile. Docs describe imports taking anywhere from ten seconds to fifteen minutes depending on build size and whether dependencies are already cached. Re-import is needed only when the build definition changes: a new dependency, module or compiler flag. Metals watches the build files and prompts you; if you dismiss the prompt, completions keep using the old classpath, which is the most common cause of "it compiles in sbt but Metals says the import does not exist".

my-service/
  build.sbt
  project/
    build.properties        # sbt version, read by the launcher
    metals.sbt              # written by Metals: adds the sbt-bloop plugin (do not commit)
  .bloop/                   # one JSON file per module, exported from sbt (do not commit)
  .bsp/sbt.json             # only if you switched to sbt as the build server
  .metals/                  # Metals database, logs, explained diagnostics (do not commit)
  .scalafmt.conf            # commit this: formatter version and style
  .jvmopts                  # JVM options for running and debugging your code
Advertisement

Choosing a build server

Bloop is the default and the Metals docs recommend staying on it unless you need a feature another server offers. It is a long-running compile server shared across workspaces, keeps compilers warm, and compiles in parallel. The cost is a second copy of your build model: Bloop compiles from its exported JSON, so custom sbt tasks, source generators that run only inside sbt, and settings computed at load time can differ from what sbt does.

sbt's own BSP server, available since sbt 1.4.1, removes that duplication: sbt compiles exactly as it does on the command line and source generators run. The price is sbt's heavier resident process and, on large builds, slower incremental feedback. Mill and Scala CLI both implement BSP as well, and scala-cli setup-ide . writes the connection file for a Scala CLI project.

Build serverStrengthCostPick it when
Bloop (default)Fast, warm compilers, shared across projectsExported build can drift from sbtMost sbt, Maven and Gradle projects
sbt BSPCompiles exactly like sbt; source generators runHeavier process, slower on big buildsHeavy code generation or custom tasks
Mill BSPNative Mill semanticsMill-specific setupThe build is already Mill
Scala CLIZero-config scripts and small appsNot for large multi-module buildsScripts, single-module tools, teaching
# Default: Metals exports the build to Bloop. The manual equivalent, useful in CI images
# or when the automatic import keeps failing, is:
sbt -Dbloop.export-jar-classifiers=sources bloopInstall

# Use sbt itself as the build server instead (sbt 1.4.1 or later):
#   Command palette -> "metals.generate-bsp-config"  (writes .bsp/sbt.json)
#   Command palette -> "metals.bsp-switch"           (pick sbt or bloop)
# Go back to Bloop: metals.bsp-switch -> bloop, or metals.reset-choice then metals.build-restart

# Scala CLI projects and single-file scripts
scala-cli setup-ide .

Presentation compiler, SemanticDB and the index

The presentation compiler runs inside the Metals JVM, one instance per Scala version in your build, and reuses the module's classpath from the build server. Because it type-checks the buffer you are typing into, it can complete members on code that has never been saved. It knows only what is on the classpath: if a dependency was added to build.sbt but the build was not re-imported, completions will not see it, however many times you save.

SemanticDB is produced only by a successful compile of the module. If one file has a type error, the build server may not emit fresh SemanticDB for that module, and references, rename and call hierarchy reflect the last good compile. That is why find references can miss a call you just wrote: it will appear after the next clean compile. Rename is the dangerous one, since it edits only the occurrences it knows about. Fix compile errors before renaming.

The source index is independent of compilation, so go to definition into a dependency works even when your own code does not compile. That is also why go to definition can succeed while hover says a symbol is unknown: two different subsystems answered.

JDKs, settings and memory

Two JDKs are involved and people conflate them. The Metals server itself runs on a JDK the extension picks: the docs require JDK 11 or later and default to 17. Your project is compiled and run with the JDK set in javaHome, which should match what CI uses. Mixing them up produces errors such as class-file version mismatches when a library needs a newer Java than the one compiling your code.

JVM options for running tests and apps from the editor come from .jvmopts and .test-jvmopts in the workspace. The serverVersion setting pins a Metals release or tries a snapshot; leave it unset unless you are testing a fix. On large workspaces, exclude .bloop, .metals and target from the editor's file watcher so thousands of generated files do not flood it.

// .vscode/settings.json (workspace settings, safe to commit)
{
  "metals.javaHome": "/usr/lib/jvm/temurin-21",   // JDK used to compile and run the project
  "metals.shutdownBloopOnEditorClose": true,       // stop Bloop when the window closes
  "files.watcherExclude": {
    "**/.bloop": true,
    "**/.metals": true,
    "**/target": true
  }
}

Commit editor-agnostic files that define behaviour, such as .scalafmt.conf and .scalafix.conf. Do not commit .metals/, .bloop/, .scala-build/ or metals.sbt; they are machine-specific and regenerated on import.

Worksheets, formatting, Scalafix and tests

A file ending in .worksheet.sc is evaluated on every save, with each statement's value shown inline. A worksheet placed inside a module's source directory sees that module's classpath, which makes it a quick way to poke at your own APIs without writing a test.

// src/main/scala/pricing/explore.worksheet.sc
// Every top-level statement is evaluated on save; results appear as inline decorations.
import pricing.*

val basket = List(Item("book", 1200), Item("pen", 150), Item("pen", 150))
val total  = Pricing.total(basket)                 // total: Long = 1500
val promo  = Pricing.applyPromo(basket, "PENS2")   // promo: Long = 1350

basket.groupMapReduce(_.sku)(_.cents)(_ + _)        // Map(book -> 1200, pen -> 300)

Formatting uses Scalafmt configured by .scalafmt.conf; Metals offers to create one and downloads the Scalafmt version the file names, so everyone formats identically. Organize imports and some code actions run Scalafix rules; see Scalafix in depth for writing rules. Test suites for JUnit, MUnit, ScalaTest and Weaver appear in the test explorer and as run and debug code lenses, launched through the build server with a debug adapter.

When a compiler error is terse, Metals can surface the compiler's -explain output; recent releases write explained diagnostics under .metals/explained-diagnostics and link them from the error.

Metals for AI assistants: the MCP server

Coding agents are much better at Scala when they can ask a compiler instead of guessing. Metals now exposes its knowledge through the Model Context Protocol. Release 1.6.6 (March 2026) added a standalone MCP server that runs without an editor, pointed at a workspace, and can generate the client configuration for supported tools.

Treat it like any other Metals client: it needs an imported build to answer accurately, and it shares the same failure modes. If the agent reports that a symbol does not exist, check the import before blaming the model.

# Run the Metals MCP server without an editor, against one workspace
metals-mcp --workspace ~/src/my-service

# Generate the MCP client configuration for a supported editor or agent;
# see the Metals docs for the accepted client names
metals-mcp --workspace ~/src/my-service --client <client-name>

Worked example: a 60-module sbt monorepo

A team has 60 sbt modules, a protobuf code generator in several modules, and Scala 2.13 with a few Scala 3 modules. Complaints: import takes twelve minutes, generated classes show as missing in the editor, and find references is unreliable.

Step one is measurement. Run Doctor shows each module's Scala version, whether SemanticDB is enabled, and the build server; the Metals log in .metals/metals.log shows how long export and first compile took. Here export takes three minutes and the first compile nine, so compile is the target. Two Scala versions also mean two presentation compilers in memory.

Step two fixes generated code. Bloop compiles from the exported model and does not run sbt tasks itself, and the protobuf generator is an sbt source generator, so after a .proto edit the editor keeps seeing the old generated classes until the build is exported again. The team switches those developers to sbt BSP with metals.bsp-switch, accepting slower incremental compiles in exchange for correctness. Developers who never touch the protobuf modules stay on Bloop.

Step three fixes references by fixing the build: a warnings-as-errors flag failed one module, which never produced SemanticDB, so references into it were missing. Relaxing fatal warnings locally, and keeping them in CI, restored navigation. Caching resolved dependencies in the developer image cut import time roughly in half, and excluding generated directories from the file watcher stopped the editor stalling on save.

Failure modes

SymptomLikely causeFirst response
New dependency not found in editor, sbt compilesBuild not re-importedRun Import build; accept the prompt next time
Find references or rename misses call sitesModule failed to compile, SemanticDB staleFix compile errors, then retry
Generated sources stale or missingBloop does not re-run sbt generatorsSwitch to sbt BSP or re-export after generation
Class file version errorsjavaHome older than a library needsSet javaHome to the CI JDK
Metals slow or out of memoryMany Scala versions, huge watcher loadExclude .bloop, .metals and target; close unused workspaces
Build server stuckStale Bloop or sbt processmetals.build-restart; enable shutdownBloopOnEditorClose

For build-tool specifics, see sbt in depth and Mill in depth; for how SemanticDB is produced as a compiler plugin, see Scala compiler plugins.

Trade-offs

Metals trades a little accuracy for a lot of speed. The presentation compiler answers in milliseconds without compiling the world, but it trusts the imported classpath. SemanticDB gives precise cross-file references, but only as fresh as the last successful compile. Bloop gives fast feedback, but maintains a copy of your build model. None of this is hidden: every answer comes from a specific source, and knowing which one tells you whether to re-import, recompile or restart.

What to do next

  1. Open your project, run Run Doctor, and confirm each module shows the right Scala version and has SemanticDB enabled.
  2. Set javaHome to the same JDK your CI uses and commit it in workspace settings.
  3. Add .metals/, .bloop/, .scala-build/ and metals.sbt to .gitignore; commit .scalafmt.conf.
  4. If your build relies on source generators or custom sbt tasks, try sbt BSP with metals.bsp-switch and compare.
  5. Always accept the re-import prompt after changing the build, and fix compile errors before renaming.
  6. If you use a coding agent, run metals-mcp --workspace against an imported build and check it can resolve a symbol from your own code.
Key takeaway: Metals is an LSP server that delegates compilation to a BSP build server, answers typing-time questions with a presentation compiler, answers navigation with SemanticDB from the last good compile, and jumps into libraries with its own source index. Stale completions mean re-import, missing references mean a failed compile, generated-code gaps mean the build server's model differs from sbt, and slowness is usually the first compile or the file watcher. Pick the build server deliberately, pin javaHome to your CI JDK, and fix compile errors before trusting a rename.