Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

mcpls gives an AI coding assistant the same understanding of your code that your editor has. Instead of reading source files as plain text, the assistant can ask a compiler-grade question and get a precise answer: what is the type of this variable, where is this function defined, who calls it, and what errors does the compiler report right now.

The problem

An AI assistant without code intelligence works with text. To answer “where is ApiError::Timeout handled?” it searches for strings, guesses which matches are real, and misses the ones that go through aliases or re-exports.

Your editor does not guess. It talks to a language server (rust-analyzer, pyright, gopls, and others) over the Language Server Protocol (LSP), which knows the real structure of the project.

What mcpls does

mcpls is a bridge. It speaks the Model Context Protocol (MCP) to your AI client and the Language Server Protocol (LSP) to the language servers:

flowchart LR
    C["AI client"] <-->|MCP| M["mcpls"] <-->|LSP| S["rust-analyzer / pyright / gopls / ..."]

It starts the language servers it needs for your project, translates every question into LSP, and returns the answer in a form the assistant can use. It exposes 31 tools, from get_hover and get_references to rename_symbol and get_diagnostics.

What you get

  • Type information. Ask what a variable or expression is, with documentation.
  • Cross-references. Find every real usage of a symbol across the workspace.
  • Semantic navigation. Jump to definitions, implementations, type definitions and declarations.
  • Real diagnostics. Read the errors and warnings the compiler or linter reports, not guesses.
  • Safe refactoring. Get a workspace-wide rename plan as a set of edits.

mcpls does not change your files. Tools such as rename_symbol and format_document return edits for the assistant to apply; every other tool is read-only.

How this book is organized

The book follows a path from first use to deep detail. Read as far as you need.

PartRead it when you want toOutcome
Part 1: Quick StartTry mcplsA working setup and your first answer in minutes
Part 2: Everyday UseUse it on real projectsConfigured servers, a tour of every tool, and fixes for common problems
Part 3: Under the HoodUnderstand or harden a deploymentArchitecture, positions, lifecycle, HTTP transport, security model
ReferenceLook something upEvery flag, environment variable and configuration key

What’s Next

Start with Installation to get the mcpls binary onto your machine.

Installation

In this chapter you install the mcpls binary and at least one language server. Both are required: mcpls is the bridge, and the language server does the analysis.

Prerequisites

  • A project in a language that has a language server, such as Rust, Python, TypeScript, Go or C/C++.
  • A terminal. Building from source needs Rust 1.99 or later.

Install mcpls

The installer scripts detect your platform, download the matching release archive, verify its SHA256 checksum and install mcpls into a per-user directory. They need no sudo or administrator rights.

Linux and macOS (installs to ~/.local/bin):

curl -fsSL https://raw.githubusercontent.com/bug-ops/mcpls/main/scripts/install.sh | sh

Windows (PowerShell) (installs to $HOME\.local\bin):

irm https://raw.githubusercontent.com/bug-ops/mcpls/main/scripts/install.ps1 | iex

On Linux and macOS, MCPLS_INSTALL_DIR changes the target directory and MCPLS_VERSION selects a release tag such as v0.7.0 instead of the latest.

Verify the install:

mcpls --version

The command prints the version. If your shell reports command not found, add the install directory to PATH (see Troubleshooting).

Other installation methods

Cargo:

cargo install mcpls

From source:

git clone https://github.com/bug-ops/mcpls
cd mcpls
cargo install --path crates/mcpls-cli

Manual download. Download the archive for your platform from GitHub Releases. Each archive has a .sha256 file next to it; verify it when you download by hand.

PlatformArchitectureArchive
Linuxx86_64mcpls-x86_64-unknown-linux-gnu.tar.gz
Linuxaarch64mcpls-aarch64-unknown-linux-gnu.tar.gz
macOSIntelmcpls-x86_64-apple-darwin.tar.gz
macOSApple Siliconmcpls-aarch64-apple-darwin.tar.gz
Windowsx86_64mcpls-x86_64-pc-windows-msvc.zip
WindowsARM64mcpls-aarch64-pc-windows-msvc.zip

Docker. The image runs mcpls over stdio and reads its configuration from /etc/mcpls/mcpls.toml:

docker run -i \
  -v "$(pwd)/mcpls.toml:/etc/mcpls/mcpls.toml:ro" \
  -v "$(pwd):/workspace:ro" \
  ghcr.io/bug-ops/mcpls:latest

The image contains no language servers, so extend it with the servers you need and list /workspace in workspace.roots.

Note: The HTTP transport is an optional build feature, not part of the prebuilt binaries or the Docker image. See Transports to build it.

Install a language server

mcpls starts language servers; it does not ship them. Install the one for your language, and make sure its executable is on your PATH:

LanguageInstallExecutable
Rustrustup component add rust-analyzerrust-analyzer
Pythonnpm install -g pyrightpyright-langserver
TypeScript, JavaScriptnpm install -g typescript-language-server typescript@6typescript-language-server
Gogo install golang.org/x/tools/gopls@latestgopls
C, C++apt install clangd or brew install llvmclangd
Ziginstall zls from your package managerzls

At least one language server must be available. If one fails to start, mcpls keeps running with the others. More languages and details are in Language Servers.

What’s Next

Next, connect mcpls to an AI client so the assistant can call its tools.

Connect an AI Client

In this chapter you register mcpls with your AI client. The client launches mcpls as a child process and talks to it over standard input and output (the stdio transport), so there is nothing to run by hand.

Prerequisites

  • mcpls --version works in a terminal (Installation).
  • A language server for your project is installed.

Claude Code

Register mcpls once for all your projects:

claude mcp add --scope user mcpls -- mcpls

Everything after -- is the command Claude Code runs. Confirm the registration:

claude mcp list

The list shows mcpls with the command mcpls. Start a new Claude Code session inside your project to load it.

Claude Code starts the server from your project directory, and mcpls uses that directory as the workspace.

Share it with a team

To commit the registration to a repository, use the project scope, which writes a .mcp.json file at the repository root:

claude mcp add --scope project mcpls -- mcpls

The file contains:

{
  "mcpServers": {
    "mcpls": {
      "command": "mcpls",
      "args": []
    }
  }
}

Important: A project-scoped file is controlled by the repository. If you analyze code you do not trust, register mcpls in your user scope instead, so the repository cannot change how mcpls is launched. See Security and Trust.

Claude Desktop and other clients

Any MCP client that can launch a stdio server works with the same entry. For Claude Desktop, edit claude_desktop_config.json:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
{
  "mcpServers": {
    "mcpls": {
      "command": "mcpls",
      "args": []
    }
  }
}

JSON does not allow trailing commas; a stray one prevents the client from reading the file. Restart the client after saving.

A desktop application may not inherit your shell PATH. If it cannot find mcpls, use the absolute path, for example "command": "/Users/you/.local/bin/mcpls". Find it with which mcpls.

Desktop clients do not always start servers in a project directory. Set the workspace explicitly with args and a configuration file, as described in Minimal Configuration.

Verify the connection

Ask the assistant:

Which mcpls tools do you have?

It lists 31 tools such as get_hover, get_definition, get_references and get_diagnostics. If the list is empty, see Troubleshooting.

What’s Next

With the connection working, ask your first question about real code.

Your First Query

In this chapter you ask the assistant three questions about your own code and see which mcpls tool answers each one. You will recognize the pattern: you ask in plain language, the assistant picks a tool, and mcpls returns facts from the language server.

Prerequisites

Ask about a type

Open a project and ask:

What is the return type of process_request in src/handlers.rs?

The assistant calls get_hover with the file and the symbol name, and answers from the language server:

You:    What is the return type of process_request in src/handlers.rs?

Claude: [get_hover] It returns Result<Response, ApiError>.
        Response is defined in src/types.rs:23.

The first query after startup can take a few seconds while the language server indexes the project. mcpls waits for indexing to finish (up to 30 seconds by default) rather than answering from a half-built index.

Find every usage

Find everywhere ApiError::Timeout is handled.

Claude: [get_references] Found 4 matches:
        - src/handlers/api.rs:89
        - src/handlers/api.rs:156
        - src/middleware/timeout.rs:34
        - tests/api_tests.rs:201

This is a semantic search: it finds real uses of that variant and skips comments, strings and unrelated names.

Check for errors

Are there compiler errors in src/main.rs?

Claude: [get_diagnostics] 2 errors:
        - line 23: cannot find value `undefined_variable` in this scope
        - line 45: mismatched types: expected `i32`, found `String`

These are the messages the compiler reports, with the line they refer to.

How the assistant addresses code

Tools that point at code take a file_path (an absolute path) and either a position or a symbol name:

  • line and character, both starting at 1.
  • symbol_name, such as process_request or Parser::parse, for a symbol defined in that file.

You rarely write these yourself. If a name matches more than one symbol, mcpls reports the candidates instead of guessing, and the assistant retries with a more specific request. Details are in Tools Overview.

What’s Next

See everyday scenarios for prompts that combine these tools into real tasks.

Everyday Scenarios

In this chapter you see how the tools combine into the tasks developers repeat every day. Each scenario shows a prompt you can paste, the tools the assistant uses, and why the result is more reliable than text search.

Prerequisites

Understand unfamiliar code

Give me an outline of src/billing.rs and explain what calculate_total does.

The assistant calls get_document_symbols for the structure of the file, then get_hover on calculate_total for its signature and documentation. Both come from the language server, so signatures are exact.

Trace where something is used

Where is calculate_total called, and which functions call it?

  1. get_references lists every usage across the workspace.
  2. prepare_call_hierarchy and get_incoming_calls show the calling functions, grouped by caller.

Add “and tell me which function each usage is inside” and the assistant passes context: "enclosing_symbol", so every location comes with the function or type that contains it.

Plan a safe rename

Rename process_data to handle_data everywhere and show me the plan.

  1. prepare_rename checks that the symbol can be renamed.
  2. rename_symbol returns the edits for every file.

mcpls returns the edits; it does not write them. The assistant applies them with its own file tools, after you approve. If the result contains a dropped field, some edits were withheld (for example files outside the workspace) and the rename is incomplete, so the assistant should say so before applying anything.

Fix compiler errors

Fix the errors in src/lib.rs.

get_diagnostics provides the exact messages and ranges. get_code_actions can add the quick fixes the language server offers, such as adding a missing import.

Which types implement the Storage trait?

go_to_implementation jumps from a trait method to its implementations; prepare_type_hierarchy with get_subtypes lists implementors for languages whose server supports type hierarchy. go_to_type_definition answers “what type is this value?” by jumping to the type itself.

Look up a symbol you cannot place

Where is something called SessionManager defined?

workspace_symbol_search searches names across the whole workspace, so the assistant does not need a file first.

What’s Next

Before you tune anything, read Minimal Configuration to see how little setup a first project needs.

Minimal Configuration

In this chapter you learn what mcpls does with no configuration, and the smallest file that adds a language or a project root. Most users need only a few lines.

Prerequisites

Zero configuration

On first run mcpls writes a default configuration file to your user config directory and starts the language servers whose project markers it finds in the workspace:

LanguageServerStarts when the workspace contains
Rustrust-analyzerCargo.toml, rust-toolchain.toml
Pythonpyrightpyproject.toml, setup.py, requirements.txt, pyrightconfig.json
TypeScripttypescript-language-serverpackage.json, tsconfig.json, jsconfig.json
Gogoplsgo.mod, go.sum
C/C++clangdCMakeLists.txt, compile_commands.json, Makefile, .clangd
Zigzlsbuild.zig, build.zig.zon

A Rust project therefore works as soon as rust-analyzer is installed. A server whose markers are absent is not started, so unused servers cost nothing.

The workspace is the directory the client launched mcpls from.

Where the configuration file lives

mcpls looks for mcpls.toml in this order and uses the first that applies:

  1. The path given with --config (or the MCPLS_CONFIG environment variable).
  2. ./mcpls.toml in the current directory, only when you pass --trust-project-config.
  3. The user config directory:
PlatformLocation
Linux$XDG_CONFIG_HOME/mcpls/mcpls.toml, else ~/.config/mcpls/mcpls.toml
macOS~/Library/Application Support/mcpls/mcpls.toml
Windows%APPDATA%\mcpls\mcpls.toml

A mcpls.toml inside a repository is ignored by default because it can name a program to run. See Security and Trust.

Add a language

A configuration file replaces the built-in server list, so list every server you want. A file with no [[lsp_servers]] entries starts no language servers at all. This file runs rust-analyzer and pyright:

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
file_patterns = ["**/*.rs"]

[[lsp_servers]]
language_id = "python"
command = "pyright-langserver"
args = ["--stdio"]
file_patterns = ["**/*.py"]

Each [[lsp_servers]] entry needs a language_id, a command that is on PATH (or an absolute path), and file_patterns that say which files it serves. Use one pattern per extension, such as **/*.ts.

Save the file in the user config directory, or anywhere and pass it explicitly:

mcpls --config /path/to/mcpls.toml

To make the client do that, put the flag in its args:

{
  "mcpServers": {
    "mcpls": {
      "command": "mcpls",
      "args": ["--config", "/path/to/mcpls.toml"]
    }
  }
}

Pin the project root

By default the workspace is the launch directory. To set it yourself, add a [workspace] section with absolute paths that exist:

[workspace]
roots = ["/Users/you/projects/myapp"]

See the Example Configuration for a complete annotated file.

What’s Next

You now have a working setup. Part 2 starts with Configuration, which covers every section you will touch in daily use.

Configuration

In this chapter you build a configuration file step by step: first the workspace, then language servers, then the settings you tune on large projects. Each step is optional; add a section only when you need it. Every key is listed in the Configuration Reference.

Prerequisites

The shape of the file

A configuration has three top-level parts, all optional:

[mcp]
# how mcpls presents itself to the client

[workspace]
# which directories to analyze, and resource limits

[[lsp_servers]]
# one entry per language server (repeat the table)

Unknown keys are rejected at startup with an error that names the key, so a typo never fails silently.

Choose the workspace

workspace.roots lists the directories the language servers analyze. An empty list (the default) means the directory mcpls was launched from.

[workspace]
roots = ["/Users/you/projects/frontend", "/Users/you/projects/backend"]

Roots must exist. A relative root resolves against the directory that holds the config file when you named it explicitly (--config, MCPLS_CONFIG, or a trusted project-local file), and against the launch directory when it comes from the user config directory. Keep roots narrow: never list your home directory.

Tools accept only files under a root. A path outside every root is rejected.

Define a language server

An [[lsp_servers]] entry says which program serves which files:

[[lsp_servers]]
language_id = "python"
command = "pyright-langserver"
args = ["--stdio"]
file_patterns = ["**/*.py", "**/*.pyi"]
  • command is looked up on PATH; an absolute path also works.
  • args carries flags. Many servers need --stdio.
  • file_patterns maps files to this server. mcpls routes by extension (or by bare name for files such as Makefile), so only the final *.EXT part matters and the directory prefix is ignored. Write one pattern per extension; brace expansion such as **/*.{ts,tsx} is rejected.

Start a server only where it applies

Add project markers so the server starts only in workspaces that contain one of them:

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
file_patterns = ["**/*.rs"]

[lsp_servers.heuristics]
project_markers = ["Cargo.toml", "rust-toolchain.toml"]

A server starts when any marker exists in the workspace tree. With no heuristics table the server always starts.

Pass options to the server

initialization_options are sent once, during the LSP handshake. settings are pushed after it and answer the server’s own configuration requests. Both use the server’s own option names:

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
file_patterns = ["**/*.rs"]

[lsp_servers.initialization_options]
cargo.features = "all"
checkOnSave.command = "clippy"

Top-level dotted keys expand into nested objects, so cargo.features = "all" becomes {"cargo": {"features": "all"}}. Check your server’s documentation for the available options.

Give the server environment variables

A language server does not inherit your full environment. mcpls passes only PATH, HOME, USERPROFILE, the temporary-directory variables and the Windows system variables. Restore anything else the server needs with env:

[[lsp_servers]]
language_id = "python"
command = "pyright-langserver"
args = ["--stdio"]
file_patterns = ["**/*.py"]

[lsp_servers.env]
VIRTUAL_ENV = "/path/to/venv"

Setting PATH here replaces the inherited value instead of extending it. To add one directory, give command as an absolute path instead.

Tune timeouts and limits for large projects

When a server is slow to start or a project is large, raise the timeouts:

[workspace]
indexing_ready_timeout_seconds = 45
max_documents = 500

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
file_patterns = ["**/*.rs"]
timeout_seconds = 120
request_timeout_seconds = 60
KeyControlsDefault
timeout_secondsThe initialize handshake at startup (1 to 900)30
request_timeout_secondsEach LSP request behind a tool call (1 to 900)30
workspace.indexing_ready_timeout_secondsHow long whole-workspace queries wait for the server to finish indexing (above 3, below 60)30
workspace.max_documentsFiles held open at once; the least recently used is closed first (0 = unlimited)100
workspace.max_file_sizeLargest file mcpls opens, in bytes (0 = unlimited, at most 1 GiB)10 MiB
workspace.max_concurrent_server_startsServers starting at the same time8

Use more than one server for a language

Two servers can share a language when each has a distinct name and at most one of them handles everything. handles restricts a server to the listed routing values:

[[lsp_servers]]
name = "pyright"
language_id = "python"
command = "pyright-langserver"
args = ["--stdio"]
file_patterns = ["**/*.py"]

[[lsp_servers]]
name = "pylsp"
language_id = "python"
command = "pylsp"
file_patterns = ["**/*.py"]
handles = ["diagnostics"]

Here pylsp answers get_diagnostics and get_cached_diagnostics, and pyright (the catch-all, with no handles) answers everything else. Conflicting claims are rejected at startup with an error naming the entries. The routing values are listed in handles.

Customize how mcpls introduces itself

The [mcp] section changes the text and tool names clients see. Use tool_prefix when you run several mcpls instances in one client:

[mcp]
title = "Billing service bridge"
instructions = "Prefer get_hover before get_definition."
tool_prefix = "billing"

With this prefix the tools are named billing_get_hover, billing_get_references, and so on. A configured instructions replaces the built-in guidance entirely.

What’s Next

Next, learn how to install and configure the language servers for each language.

Language Servers

In this chapter you set up the language servers for the languages you use and learn what to expect from each. mcpls works with any LSP 3.17 server, and six are built in.

Prerequisites

Built-in servers

These start with no configuration when their project markers are present:

LanguageServerInstall
Rustrust-analyzerrustup component add rust-analyzer
Pythonpyrightnpm install -g pyright
TypeScript, JavaScripttypescript-language-servernpm install -g typescript-language-server typescript@6
Gogoplsgo install golang.org/x/tools/gopls@latest
C, C++clangdapt install clangd, dnf install clangd, or brew install llvm
Zigzlsyour package manager

Confirm each server runs by itself before you debug mcpls, for example rust-analyzer --version or gopls version.

TypeScript needs a JavaScript TypeScript

typescript-language-server drives a JavaScript tsserver, and TypeScript 7 ships none. Install typescript@6 next to it, as above. If you want the TypeScript 7 native server instead, see TypeScript: tsserver Pinning and TypeScript 7.

Alternatives and extra servers

Replace or add servers with [[lsp_servers]] entries. Each example below is a complete entry.

Python with ty instead of pyright:

[[lsp_servers]]
language_id = "python"
command = "ty"
args = ["server"]
file_patterns = ["**/*.py", "**/*.pyi"]

[lsp_servers.heuristics]
project_markers = ["pyproject.toml", "ty.toml"]

Java with jdtls:

[[lsp_servers]]
language_id = "java"
command = "/path/to/jdtls/bin/jdtls"
file_patterns = ["**/*.java"]

Shell scripts with bash-language-server:

[[lsp_servers]]
language_id = "shellscript"
command = "bash-language-server"
args = ["start"]
file_patterns = ["**/*.sh", "**/*.bash"]

C and C++ with clangd options:

[[lsp_servers]]
language_id = "cpp"
command = "clangd"
args = ["--background-index", "--clang-tidy"]
file_patterns = ["**/*.c", "**/*.cpp", "**/*.cc", "**/*.h", "**/*.hpp"]

[lsp_servers.initialization_options]
compilationDatabasePath = "build"

Use the language_id from the default language table; it is the identifier sent to the server when a file is opened.

Files with unusual extensions

mcpls knows 30 languages by extension. To teach it another, add a mapping and a server for it:

[[workspace.language_extensions]]
extensions = ["nu"]
language_id = "nushell"

[[lsp_servers]]
language_id = "nushell"
command = "nu"
args = ["--lsp"]
file_patterns = ["**/*.nu"]

Extensions are written without the dot and are case-sensitive. If you supply any language_extensions, list every language you need, because your list replaces the defaults.

Monorepos

file_patterns cannot confine a server to a subdirectory, because routing uses the extension only. Scope servers by project instead, with workspace.roots and heuristics.project_markers:

[workspace]
roots = ["/work/monorepo/backend", "/work/monorepo/frontend"]

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
file_patterns = ["**/*.rs"]

[lsp_servers.heuristics]
project_markers = ["Cargo.toml"]

[[lsp_servers]]
language_id = "typescript"
command = "typescript-language-server"
args = ["--stdio"]
file_patterns = ["**/*.ts", "**/*.tsx"]

[lsp_servers.heuristics]
project_markers = ["package.json"]

What servers can and cannot do

Not every server supports every tool. rust-analyzer advertises neither type hierarchy nor range formatting, and typescript-language-server does not advertise type hierarchy. The get_tool_support tool reports, per language, which tools are usable, so you can check before you call them.

What’s Next

Now tour what the 31 tools can do.

Tools Overview

In this chapter you learn the conventions every mcpls tool shares: how code is addressed, what the advisory flags in results mean, and how to read errors. The next chapters then cover the 31 tools by task. Learn these rules once and every tool becomes predictable.

Prerequisites

The 31 tools at a glance

TaskTools
Read and navigate codeget_hover, get_definition, get_references, get_completions, get_document_symbols, workspace_symbol_search, get_document_highlights, get_signature_help, get_inlay_hints
Diagnostics and formattingget_diagnostics, get_cached_diagnostics, format_document, format_range, get_folding_ranges, get_selection_ranges
Refactorprepare_rename, rename_symbol, get_code_actions
Hierarchies and navigationgo_to_implementation, go_to_type_definition, go_to_declaration, prepare_call_hierarchy, get_incoming_calls, get_outgoing_calls, prepare_type_hierarchy, get_supertypes, get_subtypes
Monitor and control serversget_server_logs, get_server_messages, get_tool_support, restart_server

Every tool is read-only except restart_server, which stops and starts language server processes. Tools that produce edits (rename_symbol, format_document, format_range, get_code_actions) return them without applying them.

If you set mcp.tool_prefix, every name above gains the prefix, as in {tool_prefix}_get_hover.

Addressing code

Most tools take a file_path, which must be an absolute path to a file under a workspace root.

Position-based tools use two integers:

  • line: the line number, starting at 1.
  • character: the column, starting at 1, counted in UTF-16 code units. For ASCII text this is the character count. See Positions and Encodings for the rest.

Range tools take start_line, start_character, end_line and end_character the same way. The end line must exist in the file.

Addressing a symbol by name

get_hover, get_definition, get_references, go_to_implementation, go_to_type_definition, prepare_call_hierarchy and rename_symbol accept a symbol name instead of a position:

ParameterMeaning
symbol_nameA symbol defined in the file; may be qualified, such as Type::method or Type.method
symbol_kindOptional filter by kind (function, method, struct, …) or numeric LSP SymbolKind
containerOptional filter: only symbols directly inside a type, impl, class or module of this name

Give exactly one form. A request with both, neither, or half a position is rejected with -32602.

{
  "file_path": "/work/app/src/parser.rs",
  "symbol_name": "Parser::parse",
  "symbol_kind": "method"
}

The result carries resolved_symbol with the name, kind, container, the position that was queried and position_source (selection_range or inferred). mcpls never guesses. A name that matches several symbols, none, or whose identifier cannot be located fails with -32602 and a structured data.resolution:

resolutionMeaning
ambiguousSeveral symbols match; every candidate is listed with its position
not_foundNo symbol has that name
not_defined_in_fileThe name is an import or a plain reference; use workspace_symbol_search or a position
position_unverifiedThe identifier could not be located unambiguously

rename_symbol never produces edits for an ambiguous name. get_signature_help and get_completions take a position only.

Reading results

Truncation

List-returning tools are capped at a fixed maximum. When more exist, the result has truncated: true.

Advisory flags

Two flags never block or filter a result; they tell the assistant when to be careful.

  • out_of_workspace is set on each location that falls outside every workspace root, such as the standard library or a dependency. The location is valid; it is simply not under your roots.
  • positions_degraded appears when a column could not be converted exactly for a server that does not use UTF-16. "request" means the position you sent reached the server unconverted, so the result may describe a different symbol and should not be trusted. "response" means only returned character offsets may be inexact. The flag is omitted when everything converted exactly.

Indexing and diagnostics flags

Results that depend on the server’s index carry indexing_in_progress, which is true when the server was still indexing during the call, so an empty or partial answer may be incomplete.

Enclosing-symbol context

get_references, get_definition, go_to_implementation, go_to_type_definition, go_to_declaration and get_diagnostics accept context: "none" (the default, no extra work) or "enclosing_symbol". With the second value, each location or diagnostic gains an enclosing_symbol naming the innermost symbol that contains it:

{
  "uri": "file:///work/app/src/parser.rs",
  "range": { "start": { "line": 15, "character": 4 }, "end": { "line": 15, "character": 8 } },
  "enclosing_symbol": {
    "status": "resolved",
    "name_path": ["Parser", "parse"],
    "kind": 6,
    "range": { "start": { "line": 12, "character": 1 }, "end": { "line": 30, "character": 2 } },
    "fidelity": "hierarchical"
  }
}
statusMeaning
resolvedThe innermost containing symbol, with name_path (outermost first), LSP numeric kind, range and fidelity
top_levelThe file’s symbols were read and none contains the item
not_computedSkipped, with a reason: file_cap, out_of_workspace, tracker_limit or deadline
unavailableAttempted and failed, with a reason: capability_absent, request_failed or timed_out

not_computed and unavailable mean nothing is known; never read them as top level. The lookup costs one documentSymbol request per distinct file, capped at 16 files (or max_documents / 4 when workspace.max_documents is below 64) and a 30 second budget. An enrichment object reports files_enriched, files_skipped and cut_short. The primary result is never affected.

Key naming

Keys that mcpls defines are snake_case. Objects passed through from the language server keep LSP’s casing, such as the Diagnostic items and selectionRange on call hierarchy items.

Arguments

A tool accepts only the arguments its schema declares. An argument name the tool does not declare, such as kinds instead of kind_filter, is rejected with an error that names the first unknown field and lists the accepted ones, instead of being ignored. Every tools/list input schema carries additionalProperties: false, so a schema-validating client sees the same rule. The data field of a call or type hierarchy item stays open, because it is opaque by contract.

Kind filters (kind_filter of workspace_symbol_search and get_code_actions, symbol_kind of the symbol-addressed tools, kind of get_folding_ranges) accept their names in any case, and an unknown or over-long value is rejected as -32602 with the valid values.

Errors

Failures are returned as MCP errors with a code and a message, except malformed arguments, which come back as a tool result with isError: true. Common ones:

SituationWhat to do
No server for the file’s languageAdd an [[lsp_servers]] entry, or install the server
-32602 invalid paramsFix the path or parameters; a path outside every root is rejected
Tool result with isError: true and failed to deserialize parameters: ... (unknown field, wrong type, null for a defaulted argument)Use only the arguments the tool declares, with the declared types; the message names the field and lists the accepted ones
Server still starting (ServerInitializing, -32051, retryable)Wait and retry
Server still indexing (-32050, retryable)Wait and retry, or raise workspace.indexing_ready_timeout_seconds
Server restarted while a request was in flight (-32054, retryable)Retry the request
capability_not_advertisedThe server lacks the feature; see get_tool_support

See Troubleshooting for the fixes.

What’s Next

Start with Code Intelligence, the tools you will use most.

Code Intelligence

In this chapter you learn the tools for reading code: types, definitions, usages, outlines and completions. They answer “what is this, where does it come from, and who uses it”. Addressing and result flags are explained in Tools Overview.

Prerequisites

get_hover

Returns the type signature and documentation for the symbol at a position.

Arguments: file_path, and line with character or symbol_name (with optional symbol_kind, container).

{ "file_path": "/work/app/src/models.rs", "line": 10, "character": 5 }
{
  "contents": "```rust\nstruct User {\n    id: u64,\n    name: String,\n}\n```\n\nUser information structure.",
  "range": {
    "start": { "line": 10, "character": 5 },
    "end": { "line": 10, "character": 9 }
  }
}

The result is null when the server has no hover information for that position. Hover works best with statically typed languages.

get_definition

Returns where a symbol is defined, across files and crates.

Arguments: the same addressing as get_hover, plus optional context (see enclosing-symbol context).

{ "file_path": "/work/app/src/billing.rs", "symbol_name": "process_payment" }
[
  {
    "uri": "file:///work/app/src/billing.rs",
    "range": {
      "start": { "line": 23, "character": 0 },
      "end": { "line": 23, "character": 14 }
    },
    "out_of_workspace": false
  }
]

A symbol with several definitions returns several locations; an unknown symbol returns an empty array.

get_references

Finds every usage of a symbol in the workspace.

Arguments: addressing as above, plus include_declaration (default false) and optional context.

{
  "file_path": "/work/app/src/errors.rs",
  "symbol_name": "Timeout",
  "container": "ApiError",
  "context": "enclosing_symbol"
}

Each location has uri, range, out_of_workspace and, with context, the enclosing_symbol. Searching a very common symbol can be slow, and the list is capped (truncated: true).

get_document_symbols

Returns the outline of one file as a hierarchy of symbols (types, functions, fields) with their ranges.

{ "file_path": "/work/app/src/models.rs" }

Use it to understand a file before reading it, or to find the exact name for a symbol_name argument. Each symbol has a numeric kind such as 5 (class or struct), 6 (method), 8 (field), 11 (interface or trait) and 12 (function).

Searches symbol names across the whole workspace, with partial and fuzzy matching. It needs no file, so it is the way in when you only know a name.

Arguments: query (required), limit (default 100, capped by the server) and kind_filter (a kind name such as function or class, or the numeric LSP kind).

{ "query": "SessionManager", "kind_filter": "struct" }

This tool has no document to route on. mcpls sends it to the first server that explicitly claims workspace_symbols in handles, otherwise to the first catch-all server.

get_document_highlights

Lists the occurrences of the symbol at a position inside the same file, each marked read, write or text. It is the file-local subset of get_references and is faster.

{ "file_path": "/work/app/src/billing.rs", "line": 42, "character": 9 }

get_signature_help

Returns the signatures and parameter documentation for the call at a position, with the active signature and parameter. Position it inside the parentheses of a call.

{ "file_path": "/work/app/src/billing.rs", "line": 57, "character": 31 }

get_completions

Returns completion candidates at a position. Add trigger (for example ., : or ->) to mimic typing a trigger character.

{ "file_path": "/work/app/src/billing.rs", "line": 57, "character": 18, "trigger": "." }

Each item has a label, a numeric kind (2 method, 3 function, 5 field, 6 variable, 7 class, 9 module), a detail, optional documentation and insertText. Completion requests are capped at 10 seconds whatever request_timeout_seconds says.

get_inlay_hints

Returns the inferred type and parameter-name annotations an editor would draw inline, for a range.

{
  "file_path": "/work/app/src/billing.rs",
  "start_line": 40, "start_character": 1,
  "end_line": 60, "end_character": 1
}

Use it to see inferred types of let bindings without hovering over each one.

What’s Next

Continue with Diagnostics and Formatting to see what the compiler reports.

Diagnostics and Formatting

In this chapter you learn how to read the errors and warnings a language server reports, and how to ask for formatting and structural ranges. Diagnostics are what turn an assistant’s “this should compile” into a checked fact.

Prerequisites

get_diagnostics

Returns the errors, warnings and hints for one file, merged from a fresh pull request and the notifications the server has pushed (for example clippy results from rust-analyzer).

{ "file_path": "/work/app/src/main.rs" }
{
  "diagnostics": [
    {
      "range": {
        "start": { "line": 10, "character": 8 },
        "end": { "line": 10, "character": 24 }
      },
      "severity": "error",
      "message": "cannot find value `undefined_variable` in this scope",
      "code": "E0425"
    }
  ],
  "availability": "published",
  "origin": "pull",
  "indexing_in_progress": false,
  "push_notifications_degraded": false
}

Severity is error, warning, information or hint. Add context: "enclosing_symbol" to see which function each diagnostic is in.

Read the extra fields before you trust an empty list:

FieldValues and meaning
availabilitypublished: the server reported on the file, so an empty list means clean. pending: nothing was published yet. evicted: a publish was dropped to bound the cache. An empty list next to pending or evicted is not a clean file
originpull when a textDocument/diagnostic request answered, push_cache when the server has no pull support and the cache alone answered, cache_after_failed_pull when the pull failed and the cache answered
indexing_in_progresstrue if the server was still indexing during the read, so early errors may be false and real ones may be missing
push_notifications_degradedtrue if the server crashed and restarted during this session, so push-only diagnostics may be missing until mcpls restarts

After an edit. Once mcpls has re-synced a changed file, a pushed diagnostic from an older version of the file is not listed when the server’s pull for the current version no longer reports it, so an error you just fixed does not linger for the 250 to 350 ms a server such as pyright needs to publish again. Only items the same server’s pull reported before are dropped: pushed diagnostics that no pull ever reports (clippy results from rust-analyzer, for example) stay. If the pull failed or the server answers only by push, nothing is dropped and the latest push is the answer. Limits: a pushed item without a source that no earlier pull covered, and items of a server whose pulls never reported that source yet (for example its first pull came back empty), stay until the server publishes again.

Diagnostics from the two sources are deduplicated by severity, code and proximity (within 3 lines), without comparing messages. Two distinct errors with the same code that start within 3 lines appear once, with the pulled message.

get_cached_diagnostics

Returns what mcpls already holds for a file, with no new analysis: the diagnostics the server pushed plus the last full report a get_diagnostics call stored. It is fast and takes only file_path.

While the file’s server is still starting it returns the retryable ServerInitializing error, and if the server failed to start it returns that failure, instead of an empty list. An empty list therefore always means “no diagnostics”. The same cache backs the lsp-diagnostics:// resource; see Diagnostics and Resources.

format_document

Returns the text edits that format a whole file.

Arguments: file_path, tab_size (1 to 32, default 4) and insert_spaces (default true).

{ "file_path": "/work/app/src/main.rs", "tab_size": 4, "insert_spaces": true }

The edits are returned, not applied.

format_range

Returns the edits that format only a range. Arguments: file_path, the four range fields, tab_size and insert_spaces. A line past the end of the file is rejected.

{
  "file_path": "/work/app/src/main.rs",
  "start_line": 12, "start_character": 1,
  "end_line": 20, "end_character": 1
}

Not every server supports range formatting; rust-analyzer does not advertise it.

get_selection_ranges

Returns the ranges that enclose a position, innermost first (expression, statement, block, function, and so on). Pass one of them to get_code_actions or format_range.

{ "file_path": "/work/app/src/main.rs", "line": 14, "character": 9 }

get_folding_ranges

Returns the foldable regions of a file with 1-based lines, a kind and collapsed text, sorted by start line with the longest first. Argument kind is all (default), comment, imports or region, in any case.

{ "file_path": "/work/app/src/lib.rs", "kind": "imports" }

It is a compact way to see the shape of a large file.

What’s Next

Continue with Refactoring to turn what you found into changes.

Refactoring

In this chapter you learn the tools that propose changes: renames and code actions. They return edits and never write to disk, so the assistant stays in control of what changes and you stay in control of what is approved.

Prerequisites

prepare_rename

Checks whether the symbol at a position can be renamed, before you propose a name.

{ "file_path": "/work/app/src/billing.rs", "line": 23, "character": 8 }

The result status is one of:

statusMeaning
renameableThe rename is valid; the result carries the identifier range and, when the server provides it, a placeholder
default_behaviorThe server accepts the rename but reports no range
not_renameableThe position cannot be renamed; the server’s reason may appear as server_message

A server that does not support the check is refused with a capability error.

rename_symbol

Returns the edits that rename a symbol everywhere it is used.

Arguments: file_path, new_name, and either line with character or symbol_name (with optional symbol_kind and container).

{
  "file_path": "/work/app/src/data.rs",
  "symbol_name": "process_data",
  "new_name": "handle_data"
}
{
  "changes": [
    {
      "uri": "file:///work/app/src/data.rs",
      "edits": [
        {
          "range": {
            "start": { "line": 10, "character": 4 },
            "end": { "line": 10, "character": 16 }
          },
          "new_text": "handle_data"
        }
      ]
    }
  ]
}

Check for dropped before applying anything. When some edits were withheld, because they point outside every workspace root, use an edit shape mcpls does not translate, or exceed the per-file cap (exceeds_item_cap), the result carries a per-reason count:

{ "changes": [], "dropped": { "out_of_workspace": 1 } }

A non-empty dropped means the rename is incomplete even when changes is not empty. dropped is omitted when nothing was withheld.

An ambiguous symbol_name never produces edits; mcpls returns the candidates instead.

get_code_actions

Returns the quick fixes, refactorings and source actions available for a range, with their edits.

Arguments: file_path, the four range fields, and optional kind_filter (quickfix, refactor, source, or a more specific kind, in any case).

{
  "file_path": "/work/app/src/lib.rs",
  "start_line": 10, "start_character": 5,
  "end_line": 10, "end_character": 15,
  "kind_filter": "quickfix"
}

Use the range of a diagnostic from get_diagnostics, or a range from get_selection_ranges. The end line must exist in the file. An action’s edit.dropped, when present and non-empty, means some of its changes were withheld, as for renames. The list is capped; truncated: true means some actions, diagnostics or edits were left out.

What’s Next

Continue with Hierarchies and Navigation to explore how code connects.

Hierarchies and Navigation

In this chapter you learn the tools that follow relationships: implementations, type definitions, declarations, call graphs and type hierarchies. They answer “what implements this”, “who calls this” and “what does this extend”.

Prerequisites

Jump tools

These three take a position (and the first two also accept symbol_name) and return a list of locations with uri, range and out_of_workspace. All accept context: "enclosing_symbol".

ToolAnswersTypical use
go_to_implementationWhere is this trait method or interface member implemented?Find implementors of a trait
go_to_type_definitionWhat type does this expression have, and where is it defined?Jump from a variable to its type, not to its binding
go_to_declarationWhere is this symbol declared?C and C++ headers, interface members; servers without the concept may return the definition
{ "file_path": "/work/app/src/store.rs", "symbol_name": "Storage::save" }

An empty result is valid. Lists are capped (truncated: true).

Call hierarchy

Call hierarchy is a two-step conversation. First ask for an item, then walk from it.

  1. prepare_call_hierarchy takes a position or symbol_name and returns callable items.
  2. get_incoming_calls returns the callers of an item. get_outgoing_calls returns the functions it calls.
{ "file_path": "/work/app/src/data.rs", "symbol_name": "initialize" }

Pass the item object to the next tool exactly as prepare_call_hierarchy returned it, unchanged: { "item": <the returned object> }. An item is only meaningful to the server that produced it, so all three tools are routed together.

Type hierarchy

The same pattern applies to inheritance and trait relationships:

  1. prepare_type_hierarchy takes a position and returns type items.
  2. get_supertypes returns the base classes and implemented interfaces of an item.
  3. get_subtypes returns derived classes and implementors, one level at a time.

Feed an item back into either tool, including items they returned, to walk up or down the tree. Not every server supports type hierarchy: rust-analyzer and typescript-language-server do not advertise it, and the TypeScript 7 native server does not support it either. Ask get_tool_support first.

What’s Next

Finish the tool tour with Server Monitoring and Control.

Server Monitoring and Control

In this chapter you learn the four tools that look at the language servers themselves: what they say, what they can do, and how to restart one that has gone wrong. Reach for them when a tool answers oddly or not at all.

Prerequisites

get_tool_support

Reports which tools are usable for which languages in the current session, without making a call that is certain to fail. Call it before using a tool on a language you have not used yet.

Argument: optional file_path, which restricts the report to that file’s language.

{ "file_path": "/work/app/src/main.rs" }

Per tool, coverage is all, some, none, unknown (a server is still initializing) or always (needs no language server). routes groups languages by status:

statusMeaning
supportedThe call will be dispatched to a server that advertises the capability
push_onlyThe server publishes diagnostics but has no pull provider, so get_diagnostics answers from the push cache
capability_not_advertisedThe server does not advertise the feature
initializingThe server has not finished starting
no_serverNo server is routed for this tool

supported means the call will be dispatched, not that it will succeed: indexing, push-only diagnostics and respawn backoff can still fail it.

get_server_logs

Returns recent log messages from the language servers. Arguments: limit (default 50) and min_level, exactly one of error, warning, info or debug in lowercase.

{ "limit": 20, "min_level": "warning" }

Use it when completion or hover fails, to see messages such as a project that could not be loaded.

get_server_messages

Returns the user-facing messages servers sent with window/showMessage, such as status updates and prompts. Argument: limit (default 20).

{ "limit": 10 }

restart_server

Restarts language servers: it stops the old process (a graceful shutdown, then a kill of its whole process group) and starts a fresh one, discarding its in-memory state. Use it when a server is wedged or serves a stale index, for example after editing Cargo.toml or package.json.

Give either servers (a list of server ids, taken from name or language_id) or all: true.

{ "servers": ["rust"] }

Per server, status is:

statusMeaning
restartedA new process runs; indexing_state is unknown, loading or ready
failedThe restart failed with a typed reason; the server stays registered and the next tool call retries
throttledRestarted too recently; retry after retry_in_ms
initializingThe server is still starting
not_runningThe server never started; fix the cause and restart mcpls

This tool is destructive: it kills processes the server started, including daemons other clients may share. It is neither read-only nor idempotent. Requests in flight on the old process fail with the retryable -32054 error, and the first whole-workspace query afterwards may wait while the new server indexes.

What’s Next

If something does not work as described, go to Troubleshooting.

Troubleshooting

In this chapter you find the fix for the problems people hit most, ordered the way they appear: install, client connection, language servers, configuration, and unexpected results. Start by turning on logs, which answer most questions on their own.

Prerequisites

Read the logs first

mcpls writes logs to standard error, so they never mix with the MCP stream on standard output. Raise the level with --log-level or MCPLS_LOG:

mcpls --log-level debug 2> mcpls-debug.log

MCPLS_LOG accepts trace, debug, info, warn, error, off, or comma-separated target=level directives such as info,mcpls_core=debug. An unknown level is rejected at startup. Add --log-json for JSON lines.

From inside the assistant, the get_server_logs tool shows what the language servers logged.

Installation

Command not found: mcpls

The install directory is not on PATH. The installer scripts use ~/.local/bin; cargo install uses ~/.cargo/bin.

export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"

Add the line to ~/.zshrc or ~/.bashrc to keep it. On Windows, add the directory containing mcpls.exe to your user Path in Environment Variables and open a new terminal.

The binary is blocked on macOS

A binary downloaded by hand can carry a quarantine attribute. Remove it:

xattr -d com.apple.quarantine /path/to/mcpls

Failed to compile

Building from source needs Rust 1.99 or later:

rustup update stable

Client connection

mcpls does not appear in the client

  1. Run mcpls --version in a terminal.
  2. Check the client’s configuration file for a syntax error. JSON does not allow comments or trailing commas.
  3. Restart the client completely.
  4. If the client cannot find the binary, use its absolute path as command; which mcpls prints it.

For Claude Code, claude mcp list shows what is registered.

Test the server by hand

Send an MCP initialize request on standard input:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | mcpls

A JSON response containing serverInfo means the server starts. Use the client’s own diagnostics for anything beyond that.

Language servers

LSP server not available for file type

No [[lsp_servers]] entry maps the file’s extension. The error names the extension and the file_patterns configured across servers. Add an entry:

[[lsp_servers]]
language_id = "go"
command = "gopls"
file_patterns = ["**/*.go"]

Or the server did not start because its project markers are missing from the workspace; check the startup log for the servers that were registered.

Server failed to start

The error names the command and the reason. Check that the server runs by itself (gopls version), that command is on PATH or absolute, and that the args are right (many servers need --stdio). Remember that a language server sees a reduced environment; set what it needs in the entry’s env.

Server still initializing

ServerInitializing (-32051) is retryable. Large projects can take minutes to start. Raise timeout_seconds if the server is cut off during the handshake.

Queries are slow or time out

The first query on a large project waits for indexing. Raise the limits for that server:

[workspace]
indexing_ready_timeout_seconds = 45

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
file_patterns = ["**/*.rs"]
timeout_seconds = 120
request_timeout_seconds = 60

A single request is retried when the server answers “content modified” (-32802): up to 4 attempts with a 3.5 second total backoff, so the worst case for one call is 4 * request_timeout_seconds + 3.5 seconds. Keep workspace.roots narrow so the server does not index unrelated directories.

Queries wait after restart_server

After a restart or an automatic respawn, mcpls treats a server that reported readiness before as loading again until its replacement reports ready, or until indexing_ready_timeout_seconds passes. This prevents answers from an empty index. Retry after the -32050 or -32051 error, lower the timeout, or exempt the server from gating:

[[lsp_servers]]
language_id = "go"
command = "gopls"
file_patterns = ["**/*.go"]
indexing = "disabled"

A server crashed

mcpls respawns a crashed server on the next call that needs it. Push-only diagnostics are lost until you restart mcpls, and results say so with push_notifications_degraded: true. Call restart_server for a server that is wedged.

Results look stale after a file changed

mcpls detects changes made on disk (a checkout, a formatter, another editor) and resynchronizes the server on the next call. Two cases are not detected: a file restored with the same size and the same modification time (tar x, rsync -a, cp -p), and files mcpls has never opened, which workspace_symbol_search reads from the server’s own index. Run touch on the file, or restart the server.

Configuration

Configuration file not found or the wrong one is used

Run with --log-level debug and look for the config path in the output. Order of lookup: --config or MCPLS_CONFIG, then ./mcpls.toml only with --trust-project-config, then the user config directory (locations). A warning names a project-local file that was ignored for lack of trust.

Invalid configuration

Unknown keys, bad values and unsupported patterns fail at startup with a message naming the entry. Typical causes:

  • A TOML syntax error.
  • A missing language_id, command or other required field.
  • A file_patterns entry in an unsupported form, such as **/*.{ts,tsx}, src/** or .eslintrc.
  • A workspace.roots entry that does not exist.
  • Two servers for one language without distinct name values, or two catch-all servers.

Position out of bounds or document not found

Lines and columns start at 1, and the file must exist under a workspace root. Use an absolute path.

Unexpected results

get_diagnostics returns an empty list

Check availability. pending means the server has not published for the file yet (servers without pull support, such as typescript-language-server, only push); call again after a moment. evicted means a publish was dropped; the server’s next publish restores it. Only published with an empty list means a clean file.

A tool reports capability_not_advertised

The routed server does not support that feature: for example, type hierarchy on rust-analyzer. Run get_tool_support to see coverage per language. If every server for a language lists handles, add the routing value the tool needs to one of them (handles).

enclosing_symbol is not_computed or unavailable

reasonFix
capability_absentThe server does not advertise documentSymbolProvider
request_failed, timed_outRetry, or check get_server_logs
file_capToo many distinct files; narrow the query or raise workspace.max_documents
tracker_limitThe file could not be opened; raise workspace.max_documents
out_of_workspaceThe file is outside every root and is never opened
deadlineThe 30 second budget ran out before this file

Was not started: the workspace is untrusted

mcpls runs with --workspace-trust untrusted and the server was not allowed. Add --allow-server <id> for each server you accept. If the message says the executable lies inside the workspace, nothing overrides that; install it outside the workspace. See Untrusted-Workspace Mode.

tsserver pin warnings

mcpls could not pin TypeScript’s tsserver to the one next to typescript-language-server. See TypeScript: tsserver Pinning and TypeScript 7.

Shutdown hangs

The first SIGTERM or SIGINT starts a graceful shutdown that closes the language servers. If it takes too long, send the signal again to force an immediate exit.

Getting help

Collect the output of mcpls --version, the language server’s version, your configuration file and a --log-level debug log, then search or open an issue at https://github.com/bug-ops/mcpls/issues. Report security problems privately, as described in SECURITY.md in the repository.

What’s Next

If you want to understand why mcpls behaves this way, continue with Architecture.

Architecture

In this chapter you learn how mcpls is built and how one tool call travels from the AI client to a language server and back. Knowing the pieces explains the behavior you see in configuration, errors and timing.

Prerequisites

  • You have used mcpls from a client (Part 1).

The big picture

flowchart TB
    Client["AI client"]
    subgraph mcpls["mcpls (single binary)"]
        direction LR
        MCP["MCP server<br/>31 tools"] --> Bridge["Translation layer<br/>(bridge)"] --> LSPc["LSP clients"]
    end
    Servers["rust-analyzer, pyright,<br/>typescript-language-server, ..."]
    Client <-->|"MCP over stdio or HTTP"| MCP
    LSPc <-->|"LSP over each server's stdin/stdout"| Servers

mcpls is a single Rust binary with no runtime dependencies. It is asynchronous (Tokio), runs several language servers concurrently, and forbids unsafe code across the workspace.

Crates and modules

The workspace has two crates that matter to users:

CrateRole
mcpls (mcpls-cli)The binary: command-line parsing, logging, then a call into the library
mcpls-coreThe library that implements everything else

Inside mcpls-core:

ModuleResponsibility
configLoads TOML, discovers servers by project markers, holds the trust types
lspSpawns language servers and speaks JSON-RPC 2.0 to them; resolves which executable runs; pins tsserver
mcpThe MCP server (built on the rmcp crate): tool definitions and dispatch
runtimeStartup, supervision, shutdown, and the untrusted-workspace plan
transportstdio and HTTP serving
bridgeThe translation layer: positions, document state, diagnostics cache, routing

Life of a tool call

sequenceDiagram
    participant C as AI client
    participant M as MCP layer
    participant B as Bridge
    participant L as Language server
    C->>M: tools/call get_hover
    M->>M: validate arguments and path
    M->>B: route by file extension
    B->>L: didOpen (first use)
    B->>B: wait for indexing, resolve name, convert position
    B->>L: textDocument/hover
    L-->>B: response
    B-->>M: translate positions, flag, cap
    M-->>C: tool result

Follow get_hover with a symbol name:

  1. Validate. The MCP layer checks the arguments and that file_path is under a workspace root.
  2. Route. The router picks the language server for the file from its extension, honoring handles and falling back to a catch-all server if the preferred one failed to start.
  3. Open the document. The bridge sends textDocument/didOpen the first time a file is touched, and re-syncs it if the file changed on disk.
  4. Wait for readiness. For whole-workspace queries, mcpls waits (up to indexing_ready_timeout_seconds) until the server reports it has finished indexing.
  5. Resolve the name. A symbol_name is resolved through textDocument/documentSymbol, and the identifier position is verified in the text.
  6. Convert the position. 1-based MCP coordinates become 0-based LSP coordinates in the encoding the server negotiated (Positions and Encodings).
  7. Request. The LSP request is sent, bounded by request_timeout_seconds, and retried on “content modified”.
  8. Translate back. The response becomes the tool result: positions are converted back, locations are flagged out_of_workspace when outside the roots, and lists are capped.

Design decisions

  • Graceful degradation. Language servers start in the background and concurrently. The MCP handshake finishes without waiting for them, a slow server delays only its own languages, and a failed server never affects the others.
  • Lazy document state. Files are opened in the server on first use and closed in least-recently-used order beyond max_documents, which bounds memory.
  • Poll-friendly diagnostics. LSP pushes diagnostics; MCP clients ask for them. mcpls caches pushes and stores pull results so both views agree (Diagnostics and Resources).
  • Honest results. Where information is lost, results say so (positions_degraded, truncated, availability, dropped) rather than looking complete.
  • Safe by default. Workspace configuration is not trusted, environments are cleared, and executables are resolved explicitly (Security and Trust).

What’s Next

The next chapter explains the position conversion in step 6, the source of most subtle bugs in code bridges: Positions and Encodings.

Positions and Encodings

In this chapter you learn how mcpls converts positions between MCP and LSP, and why a result can carry positions_degraded. If you only use ASCII source files, you can skip it; it matters for text with accents, CJK characters or emoji.

Prerequisites

Two coordinate systems

MCP tools (what the assistant sees)LSP (what language servers use)
First line10
First column10
Column unitUTF-16 code unitsThe encoding negotiated with the server

All conversions go through one module, so the off-by-one adjustment is never repeated in a tool.

Why encoding matters

LSP counts the columns of a line in “code units”, and servers may differ in which unit they use. Take the line let s = "héllo";. The character é is one character, two bytes in UTF-8 and one code unit in UTF-16. A column after it differs by one between UTF-8 and UTF-16.

For ASCII text all encodings agree. They diverge after any non-ASCII character on the same line.

Negotiation

During the initialize handshake, mcpls offers the encodings in workspace.position_encodings in order. The default is ["utf-8", "utf-16"]:

[workspace]
position_encodings = ["utf-8", "utf-16", "utf-32"]

The list must be non-empty and contain only utf-8, utf-16 and utf-32. It is a preference: UTF-16 is the mandatory fallback in the LSP specification, so a server may answer with UTF-16 even if you omit it.

Converting to the server’s encoding

When the server does not use UTF-16, mcpls converts each column using the text of the line, taken from the open document or read from disk. Reading from disk is bounded by a per-response budget of four times workspace.max_file_size (four times the default when it is 0), at most 256 MiB.

When a column cannot be converted exactly, the result is still returned and is marked positions_degraded:

ValueMeaningWhat to do
"request"The position you sent reached the server unconverted, so the result may describe a different symbolDo not trust the result
"response"Only returned character offsets may be inexact; the result describes the symbol you asked aboutKeep the result, treat columns as approximate

Causes include an exhausted disk-read budget, an unresolvable path reported by the server, an oversized file, invalid UTF-8, a line past the end of the file, and a column inside a multi-unit character. Column 1 and columns past the end of a line never trigger the flag; the latter clamp to the end of the line.

What’s Next

Next, see how the language server processes themselves are started, watched and stopped: Language Server Lifecycle.

Language Server Lifecycle

In this chapter you follow a language server from selection to shutdown: how mcpls decides which servers to start, what the LSP handshake does, what happens when a server is slow or dies, and how shutdown works. This explains why a first query can wait and why a crashed server comes back on its own.

Prerequisites

1. Selection

stateDiagram-v2
    [*] --> Selected: heuristics match
    Selected --> Starting: background start
    Starting --> Ready: initialize and initialized
    Starting --> Failed: spawn or handshake error
    Ready --> Respawning: process died
    Ready --> Restarting: restart_server
    Respawning --> Ready: backoff 1 s to 30 s
    Restarting --> Ready
    Ready --> ShuttingDown: signal or client closed
    ShuttingDown --> [*]: shutdown, exit, kill group

At startup mcpls takes the configured [[lsp_servers]] entries and keeps those that apply to the workspace: an entry applies when it has no heuristics, or when any of its project_markers exists in the workspace tree. The search is recursive up to workspace.heuristics_max_depth levels (default 10, at most 64) and skips well-known directories such as node_modules, target and .git.

The remaining entries are checked for routing conflicts, and the config is refused if two applicable servers claim the same language identity or tool (handles).

2. Concurrent start

Servers start in a background task. The MCP handshake with your client completes immediately, without waiting for any language server. At most workspace.max_concurrent_server_starts (default 8) start at once, and each server becomes usable as soon as its own initialize completes, in whatever order that happens.

A tool call for a language whose server has not registered yet returns the retryable ServerInitializing error (-32051). If the server failed to start, calls return ServerFailedToStart with the command and the reason.

3. The LSP handshake

For each server mcpls:

  1. Spawns the process with a cleared environment plus an allowlist of variables and the entry’s env (Security and Trust).
  2. Sends initialize with the workspace folders, the position encodings it prefers, and the initialization_options from the entry. The request is bounded by timeout_seconds (default 30, at most 900).
  3. Reads the server’s capabilities from the reply. Tool support is derived from these.
  4. Sends initialized, and then the entry’s settings with workspace/didChangeConfiguration.

4. Steady state

  • Documents. textDocument/didOpen is sent on first use of a file. Beyond workspace.max_documents, the least recently used unlocked document is closed with didClose. Edits on disk are detected on the next call and the server is resynchronized.
  • Requests. Each is bounded by request_timeout_seconds. A “content modified” answer (-32802) is retried with exponential backoff, up to 4 attempts. Completions are capped at 10 seconds.
  • Indexing readiness. mcpls watches the server’s readiness signals (rust-analyzer’s experimental/serverStatus, or generic $/progress). Whole-workspace queries wait for a server that is still indexing, up to indexing_ready_timeout_seconds, and then fail with a “still indexing” error instead of answering from a partial index. A server that never reports a signal is never delayed, and indexing = "disabled" opts a server out.
  • Notifications. Diagnostics, log messages and showMessage notifications are cached for the tools that read them (Diagnostics and Resources).

5. Failure and recovery

If a server process dies, the next call that needs it respawns it, with a backoff that grows from 1 second up to 30 seconds. Documents are reopened on the new process, and the call is gated on the new server’s readiness. After a crash, push-only diagnostics are lost, so results carry push_notifications_degraded: true.

restart_server does the same on demand. It has a 5 second cooldown per server, and requests in flight on the old process fail with the retryable -32054.

6. Shutdown

On SIGTERM, SIGINT, or when the client closes the connection, mcpls shuts down gracefully:

  1. It stops accepting work and cancels background tasks.
  2. For each server it sends the LSP shutdown request (5 second timeout), then exit.
  3. It waits a short grace period (3 seconds) for the process to leave, then kills it together with its process group (Process Lifetime).

A second signal while cleanup is stuck forces an immediate exit. The exit code is 0 on success, 1 on an error and 101 if mcpls itself panicked, in which case the servers are still reaped.

What’s Next

Next, see how diagnostics flow through the cache and reach clients as resources: Diagnostics and Resources.

Diagnostics and Resources

In this chapter you learn how mcpls reconciles two opposite diagnostic models, and how a client can subscribe to diagnostics instead of polling for them. This is the part to read when you build a client or need to know exactly when diagnostics change.

Prerequisites

Push versus pull

LSP servers deliver diagnostics in two ways:

  • Push. The server sends textDocument/publishDiagnostics whenever it has new results. Background analyzers such as rust-analyzer’s flycheck (clippy) work only this way.
  • Pull. The client sends textDocument/diagnostic and receives a report.

MCP is request and response: an assistant asks for diagnostics when it needs them. mcpls bridges the gap with a bounded cache.

The cache

flowchart LR
    S["Language server"] -->|"publishDiagnostics (push)"| K[("Diagnostics cache")]
    S <-->|"textDocument/diagnostic (pull)"| G["get_diagnostics"]
    G -->|"store full report"| K
    K --> CD["get_cached_diagnostics"]
    K --> R["lsp-diagnostics:// resource"]
    K -.->|"resources/updated"| Sub["Subscribed clients"]
  • Every push is stored per file, keyed by the file’s canonical path. A server may publish one file under several spellings (a symlink and its target); mcpls returns the union with exact duplicates removed.
  • A full pull report from get_diagnostics is stored next to the pushed entries in a separate slot. Partial and unchanged reports, failed pulls, and reports that raced a later pull or an edit are returned to the caller but not stored.
  • The cache holds at most 1000 file entries. When a write evicts another file, that file becomes evicted, which is reported honestly instead of as clean.
  • Server log messages (100 entries) and showMessage messages (50 entries) are kept in separate bounded buffers for get_server_logs and get_server_messages.

get_diagnostics merges the pull answer with the cached pushes, deduplicating by severity, code and proximity. get_cached_diagnostics returns the cache alone and never triggers analysis. A file that is edited on disk keeps its last known diagnostics until the server publishes new ones or the next pull replaces them; there is no window where they appear empty.

Availability

Every diagnostics answer carries availability, because an empty list is ambiguous:

ValueMeaning
publishedThe server reported on the file; an empty list means clean
pendingNothing has been published since the server started; unknown
evictedA publish was dropped to bound the cache or the delivery buffer (a burst of more than 1000 files, or 64 MiB, from one server); what the server said is unknown

A server restart returns every file to pending.

Diagnostics as MCP resources

Each file’s diagnostics are also available as an MCP resource:

lsp-diagnostics:///work/app/src/main.rs

The URI uses an empty authority and the absolute path, percent-encoded. Reading it returns JSON with the same fields as get_cached_diagnostics:

FieldMeaning
trackedfalse (always with an empty list) when mcpls knows nothing about the file
versionThe document version the diagnostics were computed against, when known
diagnosticsThe LSP Diagnostic objects, with LSP’s own casing
availabilityAs above
indexing_in_progress, push_notifications_degradedAs for get_diagnostics

resources/list lists the currently open documents, 100 per page. A server that is still starting, or that failed to start, makes a read fail with the same errors as get_cached_diagnostics instead of returning an empty list.

Subscriptions

mcpls supports resources/subscribe. A subscribed session receives resources/updated:

  • once per accepted publish from the language server;
  • when a get_diagnostics pull changes the merged view of the file, before that call returns (an identical pull sends nothing);
  • when a write evicts the file from the cache, so a re-read returns its new availability.

If the file’s server is still starting, subscribing succeeds, and the subscriber receives one resources/updated if startup then fails. A session may hold at most 1000 subscriptions.

Over HTTP, stateless subscriptions/listen streams carry the same updates. They have no session and cannot answer a server ping, so they are bounded by a lease: after a random 15 to 30 minutes the response ends without a final result, and a well-behaved client listens again. mcpls replays the cached diagnostics URIs plus any eviction in the last two minutes (at most 256), so nothing is lost across the gap. There can be at most 100 concurrent listen streams. See Transports.

Redaction

Text that comes from a language server is scrubbed of secrets before it reaches logs or clients. mcpls hides the values of secret-named environment variables, secret-named --flag=value arguments and secret-keyed initialization_options strings, where the name contains TOKEN, KEY, SECRET, PASSW, CRED or AUTH (case-insensitive) and the value is at least 8 bytes. Replacements read [redacted:NAME].

This covers server log and show messages, startup and request errors, diagnostics (message, source, string code, related information and string values of data), $/progress text, trace-level wire logs and the spawn argument list. It does not cover URIs, the keys of a diagnostic’s data, or tool results such as hover text and symbol names. Matching is by exact value plus its JSON-escaped and debug-escaped spellings, so a server that re-encodes a secret (as \uXXXX, \/, URL or base64 text) can slip past it in trace-level wire logs.

What’s Next

Next, see how mcpls is served to clients, over stdio or HTTP: Transports.

Transports: stdio and HTTP

In this chapter you learn the two ways a client can reach mcpls, when to use each, and how to expose the HTTP transport safely. Almost everyone uses stdio. Choose HTTP only when the client cannot launch a process or when several clients must share one mcpls.

Prerequisites

stdio (default)

The client starts mcpls as a child process and exchanges MCP messages on its standard input and output. Logs go to standard error so they never corrupt the stream. When the client exits and closes the pipe, mcpls shuts down and stops its language servers.

stdio needs no network, no authentication and no ports, which is why it is the default and the safest choice.

HTTP (Streamable HTTP)

With HTTP, mcpls binds a TCP address and serves MCP 2025-11-25 Streamable HTTP. Several clients can connect, and mcpls runs independently of them.

Enabling the HTTP transport

HTTP is an optional Cargo feature, transport-http. The prebuilt release binaries and the Docker image are built without it, so --listen does not exist in them. Build it yourself:

cargo install mcpls --features transport-http

or, from a checkout:

cargo install --path crates/mcpls-cli --features transport-http

Start it

mcpls --listen 127.0.0.1:3000

The service is mounted at /mcp and also at /. A reverse-proxy rule must cover both. Change the mount point with --http-path:

mcpls --listen 127.0.0.1:3000 --http-path /api/mcp

The path must start with /, must not be /, must have no empty, . or .. segment, and may use only ASCII letters, digits and -._~. An invalid value exits with a usage error (code 2) before any language server starts, even without --listen.

Every HTTP option also exists as an environment variable: MCPLS_LISTEN, MCPLS_HTTP_PATH, MCPLS_HTTP_STREAM_LIVENESS, MCPLS_HTTP_ALLOWED_ORIGINS and MCPLS_HTTP_ALLOWED_HOSTS (reference).

There is no authentication

mcpls performs no authentication on any transport. Binding to a loopback address limits access to the local machine. Binding to anything else, such as 0.0.0.0, requires a reverse proxy that authenticates requests before forwarding them. Example with nginx:

location / {
    auth_request /auth;
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host localhost;
}

The Host line matters, as the next section explains.

Host and Origin allowlists

Browsers can be tricked into sending requests to local services (DNS rebinding). mcpls checks two headers on every HTTP request, Host first and then Origin.

Host

The Host header must be one of:

  • localhost, 127.0.0.1 or ::1, with any port;
  • the bound IP address, when you bound a specific non-loopback address;
  • a value you list with --http-allowed-host.

There is no wildcard. A bind to 0.0.0.0 therefore allows only the loopback names plus the hosts you list, so clients that reach it by another name need that name listed, or a proxy that rewrites Host.

mcpls --listen 0.0.0.0:8443 --http-allowed-host mcp.example.com:8443

A value is a host name or IP address with an optional port. Without a port any port matches. With one, only that port matches, and a request that omits the port does not. Clients omit :80 and :443, so pinning either, or port 0, is a usage error: list the host without a port. Wildcards, user information, a scheme, a path, an empty port, a trailing dot, a non-ASCII character (write it in punycode) and an unbracketed IPv6 address are usage errors too. Matching ignores case. Repeat the flag or separate values with commas.

Origin

A request that carries an Origin header, which browsers add, is accepted only when the origin is localhost, 127.0.0.1 or [::1] on the bound port, or one you list with --http-allowed-origin. Anything else, including Origin: null, is answered with 403. Requests without Origin, which includes every non-browser client, are not affected.

mcpls --listen 127.0.0.1:3000 \
  --http-allowed-origin https://app.example.com \
  --http-allowed-origin http://[::1]:8080

Each value is http:// or https://, a host and an optional port; a missing port means the scheme default (80 or 443). A path, a query, user information, a wildcard, null, an invalid port and an unbracketed IPv6 host are usage errors.

Allowed origins do not relax the Host check, which runs first. A browser page that reaches mcpls by a non-loopback name needs that name in --http-allowed-host as well.

Limits and timeouts

The HTTP server bounds slow and vanished clients. The defaults are:

LimitDefaultEffect
Request body4 MiBLarger requests receive 413
Header read and body-chunk pause30 sA slow request head or body is answered with 408, and idle keep-alive connections close
Write stall30 sA connection whose writes make no progress is closed, which frees a peer that stopped reading
Concurrent connections512Raise ulimit -n first on macOS, where launchd’s soft limit is 256
Concurrent sessions100New sessions beyond the limit receive 429
Session idle timeout5 minutesA session with no inbound request expires
Response stream deadline1 hourA POST or resume response stream is cut, so its session can expire

Embedders of mcpls-core can change these through HttpConfig. On the command line only the options listed in the reference are available.

Liveness and leases

mcpls probes each session’s standalone GET (SSE) stream. With --http-stream-liveness probe (the default), it sends an MCP ping every 60 seconds and closes the stream if the client does not answer, by POSTing the JSON-RPC response, within 30 seconds. This frees the stream of a vanished client, such as a sleeping laptop, and works behind a reverse proxy. A client that answers probes keeps its session until it closes the stream or sends DELETE.

A client that ignores server ping requests would be disconnected every 90 seconds. For such a client use off:

mcpls --listen 127.0.0.1:3000 --http-stream-liveness off

With off there is no proof of life, so an open stream does not hold its session: the session expires after 5 minutes without an inbound request even while notifications flow, and the client must send a request, such as ping, more often than that. off also disables the 15 to 30 minute lease on stateless subscriptions/listen streams (Diagnostics and Resources).

On Linux and Android, mcpls also sets TCP_USER_TIMEOUT (60 seconds) on accepted sockets. The probe is the portable complement, because the kernel timeout does not see through a reverse proxy.

Logging and session ids

The HTTP session id is a bearer secret. mcpls caps the log level of the rmcp targets that print it and logs only a short hash of the id. A more specific MCPLS_LOG directive for those targets overrides the cap and puts session ids back into the logs.

What’s Next

Continue with Security and Trust to learn what mcpls protects against and what it deliberately does not.

Security and Trust

In this chapter you learn what mcpls trusts, what it does not, and how to analyze code you did not write without surprises. The short version: a language server executes code that comes with the workspace, mcpls does not sandbox it, and the controls below narrow the exposure without removing it.

The authoritative policy, including how to report a vulnerability, is SECURITY.md in the repository. This chapter explains it in practical terms.

Prerequisites

Two kinds of trust

  1. Trust in the mcpls configuration. A mcpls.toml names the commands mcpls spawns, their environment and their options. Running a command from a file you did not write is code execution.
  2. Trust in the workspace. Even with a safe configuration, language servers run code that the analyzed project supplies. This is inherent to LSP and the same for every LSP bridge.

mcpls controls the first directly and narrows the second.

Project-local configuration is ignored by default

A ./mcpls.toml in the current directory is not loaded unless you pass --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true). Without it mcpls logs a warning naming the ignored file and falls through to your user config or the built-in defaults, which still start servers by project markers.

Naming a path is consent, so --config <path> and MCPLS_CONFIG are always trusted. A repository that exports MCPLS_CONFIG=./mcpls.toml from a tool such as direnv therefore loads it automatically. That is by design, but worth knowing when you audit a checkout.

Trusting a project config also lets it write the agent-facing title, description and instructions text, with nothing marking it as not coming from mcpls itself.

--trust-project-config is a grant for the whole mcpls process, not for one project. Set it on a per-project client entry, not in your shell profile or a user-wide client config.

Language servers run workspace code

ServerWorkspace-supplied code it can run
typescript-language-serverThe workspace’s node_modules/typescript/lib/tsserver.js unless pinned; tsconfig plugins; automatic type acquisition may fetch packages over the network
rust-analyzerCargo build scripts and procedural macros
pyright, pylspThe project’s Python environment, plugins and interpreters named in config
goplsThe Go toolchain, including toolchain downloads requested by go.mod
clangdCommands from compile_commands.json and .clangd configuration

Only the typescript-language-server row has been verified live; the others describe the well-known behavior of those servers.

Pointing mcpls at an untrusted checkout, such as a cloned third-party repository or a pull-request branch, can therefore run that checkout’s code. Do it only in an environment you are willing to have that code execute in, such as a container or a disposable VM.

What mcpls does to narrow the exposure

  • Cleared environment. A server starts with an empty environment plus an allowlist (PATH, HOME, USERPROFILE, TMPDIR, TEMP, TMP, and the Windows system variables), and then the entry’s env. Variables such as NODE_OPTIONS or LD_PRELOAD are not passed on unless you set them.
  • Workspace boundary. Tool calls reject paths outside every workspace root, and results flag locations outside the roots. Rename and code-action edits that target files outside the roots are withheld and counted in dropped.
  • Secret redaction. Secret-looking values are removed from text that reaches logs and clients (Diagnostics and Resources).
  • tsserver pin. The TypeScript server is pointed at its own bundled tsserver instead of the workspace’s (TypeScript).
  • No authentication by design. mcpls itself authenticates no one on any transport; see Transports.
  • Untrusted-workspace mode. A command-line mode that starts only servers you name and adds executable, configuration and environment checks (Untrusted-Workspace Mode).

Practical guidance

SituationRecommendation
Your own projectDefaults are fine
A repository you trust but did not writeReview its mcpls.toml before using --trust-project-config
A repository you do not trustUse a container or VM, and consider untrusted mode
A team-shared client configRemember that a project-scoped .mcp.json is controlled by the repository
HTTP on a non-loopback addressAlways put an authenticating reverse proxy in front

What’s Next

Next, read how untrusted-workspace mode enforces boundaries you can rely on.

Untrusted-Workspace Mode

In this chapter you learn how to run mcpls against code you do not trust: which servers start, what is checked before each launch, and what the mode does not cover. It reduces how much of the workspace can steer which program runs. It is not a sandbox.

Prerequisites

Turn it on

Untrusted mode is selected only on the command line:

mcpls --workspace-trust untrusted --allow-server rust --allow-server python
  • --workspace-trust is trusted (the default) or untrusted.
  • --allow-server <id> names a server to start. Repeat it for each server. The id is the server’s name, otherwise its language_id. An id that matches no configured server is rejected at startup.

In a client configuration:

{
  "mcpServers": {
    "mcpls": {
      "command": "mcpls",
      "args": ["--workspace-trust", "untrusted", "--allow-server", "rust"]
    }
  }
}

Neither flag has an environment variable or a configuration key, so a file planted in the workspace cannot grant consent. The mode conflicts with --trust-project-config, and --allow-server without it is a usage error (exit code 2).

Important: Put the mode in your user-scoped client configuration. A project-scoped file, such as a .mcp.json in the analyzed repository, is controlled by the repository and can drop the flag.

What it enforces

flowchart TD
    A["Server about to start"] --> B{"Named with --allow-server?"}
    B -- no --> X["Refused"]
    B -- yes --> C{"Config file outside workspace?"}
    C -- no --> X
    C -- yes --> D{"Executable resolved outside workspace and not a workspace-choosing launcher?"}
    D -- no --> X
    D -- yes --> E["Spawn with cleared environment, sanitized PATH, safe working directory"]

Every applicable server that is not allowed is refused before it can spawn, restart or respawn. A tool call routed to it returns an error naming the server and the flag that would start it.

For the servers you allow, mcpls also checks:

CheckRule
Configuration fileThe file that was loaded (--config, MCPLS_CONFIG, or the user config) must lie outside the workspace. No default config file is created in this mode
ExecutableIt is resolved the way a spawn finds it, must exist outside every workspace root, and is launched by its resolved absolute path, also on restart and respawn
PATHThe server gets a PATH with workspace, relative and empty entries removed, so an interpreter such as node cannot resolve into the workspace
EnvironmentCleared, then the allowlist. NODE_OPTIONS, LD_PRELOAD, DYLD_INSERT_LIBRARIES, PYTHONPATH, RUSTC_WRAPPER and XDG_CONFIG_HOME are not passed unless the server’s own env sets them. HOME and USERPROFILE are replaced by your login home directory from the account database
LauncherA command that lets the workspace choose the program is refused: package runners (npm, npx, bunx, pnpm, pnpx, yarn, uvx, corepack, deno npm:), task runners (make, just, task, rake, mvn, sbt), programs that run their arguments (xargs, find, awk, script, su, flock, watch), and the run subcommands of bun, deno (including eval), cargo, go, uv, pipx, poetry, pdm, hatch, bundle, dotnet, pipenv, pixi, swift, stack and cabal. A command string cannot be analyzed and is refused for the common shells (sh, bash, ash, nu, xonsh, cmd, pwsh, …; including fish -C, nushell -e and PowerShell -cwa) and for interpreters given an inline program (node -e, python -c, bun -e, perl -M with code, node --import=data:..., …); wrappers that change the user, root, directory or environment (sudo, doas, gosu, strace, unshare, chroot, nsenter, systemd-run, …) are refused outright. The wrappers with a small option grammar (env, time, nice, nohup, timeout, setsid, stdbuf, caffeinate, arch, and the busybox, toybox and coreutils --coreutils-prog= applets) are parsed: an option mcpls does not know is refused (the message names it), and the program they start is resolved, refused when it lies inside the workspace and replaced by its absolute path. A relative program that env -C starts is refused. A wrapper that is not named here (numactl, prlimit, valgrind, sg, xcrun, …) is not unwrapped, but a server is refused when any argument of its command, or the text after the first = of one, is an executable file inside the workspace (symlinks followed, relative paths resolved from the directory the server starts in); data files, directories and non-executable scripts are admitted. On file systems where every file has the execute bit (WSL drvfs, exFAT, SMB shares), every workspace file in args is refused. deno lsp is allowed
Working directoryThe server starts in your login home directory, else a system temporary directory that no other user can write to, never in the checkout
tsserverA pin that resolves inside the workspace, or a launcher no tsserver can be pinned for, is refused
WindowsNoDefaultCurrentDirectoryInExePath=1 is always set, so cmd.exe and Node servers do not find node.exe in the checkout

The launcher lists are closed, not exhaustive: a shell or interpreter that is not named in SECURITY.md is admitted with its command string unexamined, and the rules are best-effort. The trusted configuration is the boundary, so install servers globally and give their absolute path as command.

If the account’s login home cannot be determined, a server whose inherited HOME or USERPROFILE is inside the workspace, empty or unset is refused. Where $HOME legitimately differs from the account home, such as CI containers with HOME=/github/home, set HOME in that server’s env.

Example: review a pull request

Install the servers you need outside the workspace, then register mcpls in your user configuration:

claude mcp add --scope user mcpls-review -- mcpls \
  --workspace-trust untrusted --allow-server rust --config "$HOME/.config/mcpls/review.toml"

The configuration file must be outside the checkout, and rust-analyzer must be installed outside it too:

[workspace]
roots = ["/work/pr-1234"]

[[lsp_servers]]
language_id = "rust"
command = "/home/me/.cargo/bin/rust-analyzer"
file_patterns = ["**/*.rs"]

Configuring workspace.roots is advisable: with no roots, the working directory is the checkout unless it is / or your login home.

What it does not cover

  • Code the allowed servers run. Build scripts, procedural macros and tsconfig plugins are outside every check.
  • Interpreter arguments that are not executable files. node <workspace>/cli.mjs runs workspace code; only the node executable is checked, unless cli.mjs has an execute bit.
  • Launchers that pick the real server from workspace files, which are not on the launcher list: rustup honors a workspace rust-toolchain.toml whose path names a toolchain inside it; asdf, mise and Volta pick versions from workspace files (refused only for the TypeScript server); Go switches toolchains from go.mod.
  • A restarted server runs the path resolved at startup. If that path goes through a symlink inside the workspace, the symlink can be repointed later.
  • Directories above a root. A monorepo around the configured package counts as outside the workspace.
  • Hardlinks and case-insensitive file systems are not specially handled.
  • Visibility. The mode is not reported to MCP clients; refusals appear in tool error text and in the log.

The complete list is in SECURITY.md.

What’s Next

The TypeScript server has its own workspace-code vector and its own control: TypeScript: tsserver Pinning and TypeScript 7.

TypeScript: tsserver Pinning and TypeScript 7

In this chapter you learn how mcpls keeps TypeScript analysis from running the workspace’s own tsserver, and how to use the TypeScript 7 native server. TypeScript is the one language where the choice of server executable is subtle, so it gets its own chapter.

Prerequisites

The problem

typescript-language-server finds a tsserver to do the real analysis. By default it prefers the one in the workspace’s node_modules/typescript, which is workspace-supplied code that can load tsconfig plugins.

The pin

By default mcpls passes the tsserver bundled next to typescript-language-server as initializationOptions.tsserver.path, so the workspace’s own copy is not selected. The pin applies to these installs, each with a typescript package whose package.json has a version, next to the server:

  • npm -g style symlink installs;
  • npm .cmd, .ps1 and extensionless shims beside a node_modules that holds the server;
  • pnpm global installs, found under <PNPM_HOME>/global;
  • node or bun running the server’s absolute cli.mjs.

Shims are located by file existence and are never executed or parsed.

When the pin is not applied

mcpls logs a warning naming the reason, and the server runs unpinned, in these cases:

CaseWhat to do
Server not found on PATHInstall typescript-language-server or fix command
Started through a Volta, asdf or mise shim, or another wrapper scriptSet initialization_options.tsserver.path to a trusted tsserver.js
Started through npx, bunx, pnpm dlx, yarn dlx, deno npm:, or node/bun with a relative script pathUse the typescript-language-server executable or an absolute cli.mjs, or set tsserver.path
A pnpm global install with more than one global/<version> entry holding the server, or more than 16 entriesRemove stale entries, or set tsserver.path
No valid typescript package next to the servernpm install -g typescript@6
Only TypeScript 7 or later next to the server, which ships no tsserverInstall typescript@6 next to the server, or use the native server below
You set initialization_options without tsserver.pathAdd tsserver.path to keep the pin
A warning after startup that the server reports a version source other than user-settingThe pin did not take effect, for example after a restart with a stale pin

A tsserver.path you set always wins. Set it to a workspace path to opt back in to the workspace’s TypeScript:

[[lsp_servers]]
language_id = "typescript"
command = "typescript-language-server"
args = ["--stdio"]
file_patterns = ["**/*.ts", "**/*.tsx"]

[lsp_servers.initialization_options]
tsserver.path = "/opt/typescript/lib/tsserver.js"

The pin is resolved once per start, and again on respawn when the pinned path no longer resolves to the same file. A server installed inside the workspace is pinned, but that narrows nothing, because the server itself is workspace code. Automatic type acquisition network fetches are not prevented. The pin narrows one vector; it does not make an untrusted workspace safe. Volta, asdf, mise and package-runner launchers are not covered.

TypeScript 7 and the native server

TypeScript 7 is the native port of the compiler. Its npm package ships no lib/tsserver.js, so typescript-language-server cannot use it. You have three options.

Option 1: keep the JavaScript server

Install a JavaScript-based TypeScript next to it:

npm install -g typescript-language-server typescript@6

Installing typescript@6 globally replaces a global TypeScript 7 tsc. To keep both, install TypeScript 7 into a separate prefix, for example npm install --prefix ~/ts7 typescript@7.

Option 2: let mcpls choose

The generated default typescript entry carries selection = "auto". Add the key to an existing entry to opt in; it is valid only on a typescript-language-server command.

[[lsp_servers]]
language_id = "typescript"
command = "typescript-language-server"
args = ["--stdio"]
file_patterns = ["**/*.ts", "**/*.tsx"]
selection = "auto"

At startup mcpls then starts the native server, tsc --lsp --stdio, when TypeScript 7 is installed outside every workspace root and no JavaScript tsserver next to typescript-language-server can be pinned. Details:

  • It looks next to typescript-language-server, then for tsc on PATH. Only a typescript/bin/tsc of a TypeScript 7 install is accepted, never one inside the workspace (symlinks included).
  • Otherwise typescript-language-server is kept, also when tsc needs node and node is not on PATH. The choice and its reason are logged at info level.
  • A TypeScript 6 install next to typescript-language-server wins over a TypeScript 7 one.
  • An initialization_options.tsserver.path you set always keeps typescript-language-server.
  • When the native server is chosen, mcpls replaces the entry’s command and args. Remove selection to keep your own.
  • On Windows the npm or pnpm tsc.cmd shim is mapped to its typescript package and started as node <package>\bin\tsc --lsp --stdio, never through cmd.exe, with the first node.exe on PATH. If that node or its directory lies inside a workspace root, the TypeScript language server is kept. This launch has not been verified on a live Windows install, so Option 3 is the safe choice there.

Option 3: run the native server explicitly

Edit the existing typescript entry of your config file (or remove it first) so that its command and args are as below, and remove its selection key, because selection = "auto" is rejected on any other command:

[[lsp_servers]]
language_id = "typescript"
command = "/home/me/ts7/node_modules/.bin/tsc"
args = ["--lsp", "--stdio"]
file_patterns = ["**/*.ts", "**/*.tsx"]

Use the absolute path of a TypeScript 7 install outside the workspace: <npm prefix>/bin/tsc (<npm prefix>\tsc.cmd on Windows). A tsc from the workspace’s node_modules, or a bare tsc that PATH may resolve into the workspace, is workspace-supplied code that mcpls would run, and the pin does not apply to it.

Do not add this as a second typescript entry next to the default one. Two entries for one language that both omit handles are rejected at startup, with a “duplicate server id” error when both are unnamed and with a “two catch-all servers” error even when the new one has its own name.

The native server reports diagnostics through pull requests only, does not support type hierarchy, and loads no tsconfig plugins.

What’s Next

Next, see how mcpls makes sure no language server process outlives it: Process Lifetime.

Process Lifetime

In this chapter you learn what happens to language server processes when mcpls exits, whether normally or not, and the platform caveats. Language servers can start heavy children such as cargo check or build daemons, and a bridge that leaks them would slowly eat your machine.

Prerequisites

The guarantee

When mcpls exits for any reason, including SIGKILL and out-of-memory kills, the language servers and the descendants that stay in their process tree are killed. This includes processes that are meant to outlive their server, such as shared build daemons like Gradle or Bloop that the server started.

The mechanism differs by platform.

Unix

Each server has its own lifeline: a small anchor process leads the server’s process group, and a watchdog sits outside the group. When mcpls exits, or the server is shut down or restarted, the watchdog freezes the tree, finds descendants that left the group with setsid() or setpgid() (for example the cargo check that rust-analyzer’s flycheck runs), and kills everything. A crashed server’s leftovers are killed when the crash is noticed.

Caveats:

  • The sweep needs ps and awk. Without ps, mcpls logs a warning and escaped descendants survive.
  • Descendants whose parent exited before the sweep (reparented to init) cannot be attributed and may survive. A pkill -9 that matches lsp-lifeline-watchdog kills the watchdog and prevents the sweep.
  • Servers that exit only on stdin EOF wait out the 3 second shutdown grace and are then killed. processId is sent as null in initialize.
  • Servers run in their own process group, so Ctrl-C in the terminal no longer reaches them directly. A descendant that reads /dev/tty, such as an ssh or git credential prompt, may be stopped by SIGTTIN when mcpls runs in an interactive terminal.
  • If the anchor cannot be started, the server runs unbound in its own process group. If only the watchdog fails, the group is killed from mcpls on exit but escaped descendants are not swept. Ctrl-C does not reach servers in either case.

Windows

Servers run in a job object without breakaway, and the whole tree is killed. A descendant that requests CREATE_BREAKAWAY_FROM_JOB fails to spawn. If the job cannot be created or assigned, the server spawn fails.

Why this matters to you

  • You can kill mcpls or your client at any moment without leaving rust-analyzer or its children behind.
  • Shared daemons started by a server do not survive it. If a server needs a daemon that outlives it, start that daemon yourself, outside mcpls.
  • restart_server uses the same machinery: it kills the whole tree before starting a replacement.

What’s Next

Finish Part 3 with Limits and Known Constraints, a checklist of the boundaries to plan around.

Limits and Known Constraints

In this chapter you find every boundary mcpls enforces or cannot cross, in one place. Use it to plan a deployment, and to recognize a limit when a result looks cut short.

Prerequisites

Resource limits you can configure

LimitDefaultWhere to change it
Open documents100 (least recently used closed first)workspace.max_documents
Largest file opened10 MiB (at most 1 GiB)workspace.max_file_size
Servers starting at once8workspace.max_concurrent_server_starts
Wait for indexing30 s (above 3, below 60)workspace.indexing_ready_timeout_seconds
Handshake timeout30 s (1 to 900)timeout_seconds
Request timeout30 s (1 to 900)request_timeout_seconds
Project-marker search depth10 (at most 64)workspace.heuristics_max_depth
mcp.title, description, instructions, tool_prefix128, 1024, 4096 and 32 bytes[mcp]

Limits that are fixed

LimitValue
Completion request10 s, whatever request_timeout_seconds says
Retries on “content modified”4 attempts, 3.5 s of total backoff
shutdown request to a server5 s
Child exit grace after shutdown3 s
restart_server cooldown5 s per server
Respawn backoff1 s growing to 30 s
Server ids per restart_server call64
Diagnostics cache1000 files
Server log buffer, server message buffer100 and 50 entries
Subscriptions per session1000
Concurrent subscriptions/listen streams100
Enclosing-symbol enrichment16 files (or max_documents / 4 when max_documents is below 64), 30 s
Per-response disk read for position conversion4 times max_file_size, at most 256 MiB
Result listsCapped per tool; truncated: true marks a cut

Behavioral constraints

  • mcpls does not write your files. Edits are returned for the client to apply.
  • Routing is by extension. file_patterns cannot confine a server to a directory, and two servers that claim the same extension both receive every file with it unless handles separates them.
  • file_patterns forms. Only *.EXT (with an optional **/ prefix) and bare extensionless names such as Makefile are accepted. Brace expansion, character classes, ?, dotfiles, dotted names, single files and multi-part extensions such as *.tar.gz are rejected at startup.
  • workspace_symbol_search has no document. It goes to the first server that claims workspace_symbols, else the first catch-all, and fails if there is none.
  • A configuration file replaces the built-in server list. A file with no [[lsp_servers]] starts no servers.
  • Same-size restores are invisible. A file restored with an identical size and modification time is not seen as changed; see Troubleshooting.
  • Position conversion can degrade for non-UTF-16 servers (Positions and Encodings).
  • Not every server supports every tool. Use get_tool_support.
  • Servers see a reduced environment. Restore variables through env.

Transport constraints

  • HTTP is opt-in at build time (transport-http) and absent from prebuilt binaries and the Docker image.
  • HTTP/1 only. No HTTP/2.
  • No authentication on any transport. Use loopback or an authenticating reverse proxy.
  • No wildcards in the Host and Origin allowlists.

Security constraints

  • mcpls does not sandbox language servers; an allowed server runs workspace code.
  • The tsserver pin does not cover version-manager shims, package runners or relative-script wrappers.
  • Untrusted mode does not check interpreter arguments or launchers that choose the server from workspace files (Untrusted-Workspace Mode).
  • Redaction does not cover URIs, diagnostic data keys, or tool results such as hover text (Diagnostics and Resources).
  • Platform notes: the Windows launch of the native TypeScript server and the Windows USERPROFILE replacement were verified less than the Unix paths. Where a Windows layout is covered only by unit tests, prefer explicit absolute paths.

What’s Next

For a lookup of every flag and key, go to the Command Line and Environment and Configuration Reference pages.

Command Line and Environment

In this chapter you find every command-line flag of the mcpls binary and its environment variable. The flags are the same ones mcpls --help prints. Options marked HTTP exist only in builds with the transport-http feature (enabling it).

Usage

mcpls [OPTIONS]

mcpls takes no subcommands. With no options it serves MCP over stdio using the discovered configuration.

Options

FlagEnvironment variableDefaultDescription
-c, --config <FILE>MCPLS_CONFIGdiscoveredPath to the configuration file. A named path is always trusted
--trust-project-configMCPLS_TRUST_PROJECT_CONFIGoffLoad a ./mcpls.toml found in the current directory
--workspace-trust <MODE>nonetrustedtrusted or untrusted; command line only
--allow-server <ID>nonenoneServer to start in untrusted mode; repeatable; command line only
-l, --log-level <LEVEL>MCPLS_LOGinfoA level or comma-separated target=level directives
--log-jsonMCPLS_LOG_JSONoffOutput logs as JSON lines
--listen <ADDR> (HTTP)MCPLS_LISTENnoneServe Streamable HTTP on this address instead of stdio
--http-path <PATH> (HTTP)MCPLS_HTTP_PATH/mcpURL path the service is mounted at
--http-stream-liveness <MODE> (HTTP)MCPLS_HTTP_STREAM_LIVENESSprobeprobe or off
--http-allowed-origin <ORIGIN> (HTTP)MCPLS_HTTP_ALLOWED_ORIGINSnoneExtra browser origin; repeatable or comma-separated
--http-allowed-host <HOST> (HTTP)MCPLS_HTTP_ALLOWED_HOSTSnoneExtra Host value; repeatable or comma-separated
-h, --helpnonePrint help
-V, --versionnonePrint the version

Boolean values (MCPLS_TRUST_PROJECT_CONFIG, MCPLS_LOG_JSON) accept 1/0, true/false, yes/no, y/n and on/off, in any case. Any other value, including an empty string, is a startup error.

Details

--config

Without it, mcpls searches in order for $MCPLS_CONFIG, a trusted ./mcpls.toml, and the platform user config:

PlatformLocation
Linux$XDG_CONFIG_HOME/mcpls/mcpls.toml, else ~/.config/mcpls/mcpls.toml
macOS~/Library/Application Support/mcpls/mcpls.toml
Windows%APPDATA%\mcpls\mcpls.toml

If none exists, mcpls writes a default file in the user config directory (not in untrusted mode) and continues with the built-in servers.

--trust-project-config

A mcpls.toml in the current directory can name the program mcpls runs, so it is ignored unless you pass this flag. It is a grant for the whole process. See Security and Trust.

--workspace-trust and --allow-server

With untrusted, only the servers named by --allow-server start, and the executable, configuration and environment checks apply. --allow-server without untrusted is a usage error, as is combining untrusted with --trust-project-config. See Untrusted-Workspace Mode.

--log-level

Accepts trace, debug, info, warn, error and off, in any case, or directives such as info,mcpls_core=debug. A bare word must be a level: mcpls_core alone is rejected, write mcpls_core=trace. An unknown level is rejected at startup. Logs go to standard error.

--listen and the HTTP options

See Transports for the behavior, limits and the Host and Origin rules. Every invalid value of an HTTP option is a usage error with exit code 2. --http-path is validated even when --listen is not given.

Exit codes

CodeMeaning
0Clean shutdown
1A startup or runtime error, logged before exit
2A command-line usage error
101mcpls panicked; language servers are still reaped

What’s Next

For the file format, see the Configuration Reference.

Configuration Reference

In this chapter you find every key of mcpls.toml, with type, default and rules. For a guided introduction read Configuration first. Unknown keys are rejected at startup.

File structure

[mcp]                   # optional presentation overrides
[workspace]             # optional workspace settings and limits
[[workspace.language_extensions]]   # optional, repeatable
[[lsp_servers]]         # one table per language server
[lsp_servers.heuristics]
[lsp_servers.initialization_options]
[lsp_servers.settings]
[lsp_servers.env]

Where the file is found is described in Command Line and Environment.

[mcp]

Overrides the text mcpls reports about itself. Every field is optional; omitting one keeps the built-in text. serverInfo.name, version and website_url are not configurable.

KeyOverridesDefaultMaximum
titleserverInfo.titleMCPLS - MCP to LSP Bridge128 bytes
descriptionserverInfo.descriptionThe crate description1024 bytes
instructionsServerInfo.instructionsBuilt-in capability text4096 bytes
tool_prefixEvery tool name, as {tool_prefix}_{tool}No prefix32 bytes

Limits are in UTF-8 bytes. A whitespace-only value is rejected as empty; omit the key instead. instructions replaces the built-in text rather than appending to it. tool_prefix may contain only ASCII letters, digits, _ and -, and must start and end with a letter or digit.

[workspace]

roots

Array of strings. Default [], meaning the process working directory.

Directories the servers analyze. Each must exist and is canonicalized before any server starts. An empty string is rejected. A relative root resolves against the directory of the config file when the file was named explicitly (--config, MCPLS_CONFIG, or a trusted project-local file), and against the process working directory when the file came from the user config directory. A file path is also accepted under a root-level system symlink spelling of a root (for example /tmp/proj for a root at /private/tmp/proj on macOS); links deeper in the tree are not admitted.

position_encodings

Array of "utf-8", "utf-16", "utf-32". Default ["utf-8", "utf-16"].

Encodings offered to each server during initialize, in order. Must be non-empty. See Positions and Encodings.

heuristics_max_depth

Integer, at most 64. Default 10.

How deep the recursive project-marker search goes.

max_documents

Integer. Default 100; 0 disables the limit.

Documents kept open at once. At the limit the least recently used unlocked document is closed; if every document is in use, the call fails with a “document limit exceeded” error.

max_file_size

Integer, bytes. Default 10485760 (10 MiB); 0 disables the limit. Values above 1073741824 (1 GiB) are rejected.

Largest file mcpls opens; larger files fail with a “file size limit exceeded” error.

max_concurrent_server_starts

Integer, at least 1. Default 8.

Servers starting at the same time. Others start as earlier ones finish initialize, in configuration order.

indexing_ready_timeout_seconds

Integer, above 3 and below 60. Default 30.

How long whole-workspace queries (hover, definition, references, rename, completions, code actions, call hierarchy) wait for the routed server to finish indexing, once a readiness signal has shown that indexing is in progress.

language_extensions

Array of tables with extensions (array of strings, no leading dot, case-sensitive) and language_id. Default: the 30 mappings below.

[[workspace.language_extensions]]
extensions = ["py", "pyi", "pyw"]
language_id = "python"

Supplying any mapping replaces the defaults, so include every language you use.

Default language mappings

LanguageExtensionsLanguage ID
Rustrsrust
Pythonpy, pyw, pyipython
JavaScriptjs, mjs, cjsjavascript
TypeScriptts, mts, ctstypescript
TypeScript Reacttsxtypescriptreact
JavaScript Reactjsxjavascriptreact
Gogogo
Cc, hc
C++cpp, cc, cxx, hpp, hh, hxxcpp
Javajavajava
Rubyrbruby
PHPphpphp
Swiftswiftswift
Kotlinkt, ktskotlin
Scalascala, scscala
Zigzigzig
Lualualua
Shellsh, bash, zshshellscript
JSONjsonjson
TOMLtomltoml
YAMLyaml, ymlyaml
XMLxmlxml
HTMLhtml, htmhtml
CSScsscss
SCSSscssscss
Lesslessless
Markdownmd, markdownmarkdown
C#cscsharp
F#fs, fsi, fsxfsharp
Rr, Rr

[[lsp_servers]]

One table per language server.

KeyTypeDefaultDescription
language_idstringrequiredLanguage identifier sent to the server
commandstringrequiredExecutable, on PATH or absolute
argsarray of strings[]Command-line arguments
file_patternsarray of strings[]Files this server serves (forms)
timeout_secondsinteger, 1 to 90030The initialize handshake
request_timeout_secondsinteger, 1 to 90030Each LSP request behind a tool call
initialization_optionstablenoneSent in initialize
settingstablenonePushed after initialized
envtable of strings{}Environment variables for the server
heuristicstablenoneProject markers
namestringthe language_idRouting identity
handlesarray of stringsunset (catch-all)Routing values this server serves
indexing"disabled"unsetOpt out of indexing gating
selection"explicit" or "auto""explicit"Native TypeScript 7 selection

file_patterns

mcpls routes a file by its extension, or by its name when it has none, so a pattern only maps one extension or one extensionless name to this server’s language_id.

  • A final segment *.EXT, where EXT is letters, digits, _, - or +, for example **/*.rs. The directory part (**/, src/**/) is accepted and ignored.
  • One pattern per extension: ["**/*.cpp", "**/*.h"]. Brace expansion is not supported.
  • An extensionless file by bare name, NAME or **/NAME, such as Makefile. The name is letters, digits, _, - or +, case-sensitive, with no directory part other than **/.
  • Everything else is rejected at startup with an error naming the entry and pattern: character classes, ?, src/**, **/*, dotfiles and dotted names (.eslintrc, Makefile.am), single files (src/main.rs) and multi-part extensions (**/*.tar.gz).

A file with no mapping fails with no LSP server configured for language: plaintext, followed by the file’s extension or name and the configured patterns.

timeout_seconds and request_timeout_seconds

timeout_seconds bounds the initialize handshake only. Raise it for servers that load a large project before answering, such as OmniSharp on a big solution.

request_timeout_seconds bounds one request attempt. When a server answers “content modified” (-32802), mcpls retries up to 4 attempts in total with 0.5 s, 1 s and 2 s of backoff, so the worst-case latency of one tool call is 4 * request_timeout_seconds + 3.5 seconds, plus timeout_seconds if a respawn is needed. Completions are further capped at 10 seconds. The shutdown request during teardown uses a fixed 5 seconds.

initialization_options and settings

Free-form tables in the server’s own vocabulary. Top-level dotted keys expand into nested objects, while keys inside values are left untouched. An empty settings table is rejected, and so is a TOML datetime.

[lsp_servers.initialization_options]
cargo.features = "all"

[lsp_servers.settings]
"python.analysis.typeCheckingMode" = "strict"

For typescript-language-server, mcpls adds tsserver.path automatically unless you set it, or set other options without it (TypeScript).

env

The server does not inherit mcpls’s environment. It is cleared, then PATH, HOME, USERPROFILE, TMPDIR, TEMP, TMP and the Windows system variables are passed on, and then env is applied and can override any of them. Setting PATH replaces the passthrough value. On Unix, your PATH is searched first, so a bare command becomes unresolvable unless your PATH contains it; Windows falls back to the parent’s PATH. To add a directory, give an absolute command instead.

heuristics

[lsp_servers.heuristics]
project_markers = ["Cargo.toml", "rust-toolchain.toml"]

The server starts if any marker exists in the workspace tree, searched to heuristics_max_depth and skipping directories such as node_modules, target and .git. An empty list, or no table, means the server always starts.

name

The routing identity, used by --allow-server, restart_server and error messages. Two servers may share a language_id, but each needs a distinct identity.

handles

Restricts a server to exactly the listed routing values; unset means catch-all, serving every tool no other server claims for that language. The values are routing identifiers, not tool names:

handles valueTools it governs
hoverget_hover
definitionget_definition
type_definitiongo_to_type_definition
declarationgo_to_declaration
implementationgo_to_implementation
referencesget_references
diagnosticsget_diagnostics and get_cached_diagnostics
renamerename_symbol, prepare_rename
completionsget_completions
signature_helpget_signature_help
document_symbolsget_document_symbols
workspace_symbolsworkspace_symbol_search
format_documentformat_document
format_rangeformat_range
code_actionsget_code_actions
call_hierarchyprepare_call_hierarchy, get_incoming_calls, get_outgoing_calls
type_hierarchyprepare_type_hierarchy, get_supertypes, get_subtypes
document_highlightsget_document_highlights
inlay_hintsget_inlay_hints
selection_rangeget_selection_ranges
folding_rangeget_folding_ranges

The hierarchy values each govern one route, because an item is meaningful only to the server that produced it. Both diagnostics tools share a route so they are always answered by the same server.

Rules:

  • At most one server per language may omit handles, and a tool may be claimed by one server per language.
  • If two servers for one language are both applicable in the same workspace and share an identity, both omit handles, or both claim a tool, mcpls refuses to start and names the entries. Mutually exclusive heuristics make a pair unambiguous.
  • If the server a tool is routed to fails to spawn, the tool falls back to the language’s catch-all, if one is running. Otherwise it reports that no server is available, and never falls back to a server that declined it through handles.
  • When a language has no catch-all, mcpls logs a warning naming the routing values no server claims.
  • workspace_symbol_search has no language. It goes to the first server that claims workspace_symbols, else the first catch-all, and fails if neither exists.

indexing

Set to "disabled" to exempt this server from indexing-readiness gating, for a server whose readiness signal is unreliable or too slow to use as a gate.

selection

Valid only on a typescript-language-server command, and written on the generated default typescript entry. "auto" lets mcpls replace command and args with the native TypeScript 7 server. See TypeScript.

Complete examples

Python, Go and TypeScript

[workspace]
roots = ["/Users/you/projects/myapp"]

[[lsp_servers]]
language_id = "python"
command = "pyright-langserver"
args = ["--stdio"]
file_patterns = ["**/*.py"]
timeout_seconds = 45

[lsp_servers.initialization_options]
python.analysis.typeCheckingMode = "basic"
python.analysis.autoSearchPaths = true

[[lsp_servers]]
language_id = "go"
command = "gopls"
file_patterns = ["**/*.go"]

[lsp_servers.initialization_options]
analyses.unusedparams = true
staticcheck = true

[[lsp_servers]]
language_id = "typescript"
command = "typescript-language-server"
args = ["--stdio"]
file_patterns = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]

[lsp_servers.initialization_options]
preferences.quotePreference = "single"
preferences.importModuleSpecifierPreference = "relative"

Everything at once

[mcp]
title = "My Custom Bridge"
description = "Internal LSP bridge for Acme Corp"
instructions = "Use get_hover before get_definition."
tool_prefix = "optics"

[workspace]
roots = ["/path/to/project"]
position_encodings = ["utf-8", "utf-16"]
heuristics_max_depth = 10
max_documents = 100
max_file_size = 10485760
max_concurrent_server_starts = 8
indexing_ready_timeout_seconds = 30

[[workspace.language_extensions]]
extensions = ["nu"]
language_id = "nushell"

[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
args = []
file_patterns = ["**/*.rs"]
timeout_seconds = 30
request_timeout_seconds = 30

[lsp_servers.heuristics]
project_markers = ["Cargo.toml", "rust-toolchain.toml"]

[lsp_servers.initialization_options]
cargo.features = "all"
checkOnSave.command = "clippy"

What’s Next

If a configuration does not behave as you expect, see Troubleshooting.

Example Configuration

This page shows a complete, annotated mcpls.toml that you can copy and trim. Every line that is commented out is a working option you can enable by removing the #. Each key is described in the Configuration Reference.

Save it as mcpls.toml in your user config directory, or pass its path with --config. A ./mcpls.toml inside a project is ignored unless you pass --trust-project-config (Security and Trust).

# Example MCPLS configuration file
# Copy to ~/.config/mcpls/mcpls.toml or specify with --config flag.
# If placed as ./mcpls.toml in a project directory instead, mcpls ignores it
# unless you pass --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) —
# see https://bug-ops.github.io/mcpls/advanced/security.html.

# MCP serverInfo/initialize presentation overrides (all optional; omit any
# field to keep mcpls's built-in text). `instructions` REPLACES the built-in
# capability blurb rather than appending to it. Byte caps: title 128,
# description 1024, instructions 4096 (UTF-8 bytes).
# [mcp]
# title = "My Custom Bridge"
# description = "Internal LSP bridge for Acme Corp"
# instructions = "Use get_hover before get_definition."
# tool_prefix = "optics"

# Workspace settings
[workspace]
# Root directories for the workspace
roots = [
    # Add your project paths here
    # "/home/user/projects/myproject",
]

# Position encoding preference (utf-8 is more efficient for Rust)
position_encodings = ["utf-8", "utf-16"]

# Limits (all optional; the values shown are the defaults)
# heuristics_max_depth = 10                # project-marker search depth, at most 64
# max_documents = 100                      # open documents at once, 0 disables
# max_file_size = 10485760                 # bytes, 0 disables, at most 1 GiB
# max_concurrent_server_starts = 8         # servers initializing at once
# indexing_ready_timeout_seconds = 30      # above 3 and below 60

# Language extension mappings (optional)
# mcpls recognizes 30 languages by default. Customize here to:
# - Add support for specialized file types
# - Override default associations
# - Include only languages you need

# Example: Add Nushell support
# [[workspace.language_extensions]]
# extensions = ["nu"]
# language_id = "nushell"

# Example: Override Rust detection to use custom language ID
# [[workspace.language_extensions]]
# extensions = ["rs"]
# language_id = "custom-rust"

# Default language extensions (automatically included if not specified):
# - Rust: rs
# - Python: py, pyw, pyi
# - JavaScript: js, mjs, cjs
# - TypeScript: ts, mts, cts, tsx
# - Go: go
# - C: c, h
# - C++: cpp, cc, cxx, hpp, hh, hxx
# - Java: java
# - Ruby: rb
# - PHP: php
# - Swift: swift
# - Kotlin: kt, kts
# - Scala: scala, sc
# - Zig: zig
# - Lua: lua
# - Shell: sh, bash, zsh
# - JSON: json
# - TOML: toml
# - YAML: yaml, yml
# - XML: xml
# - HTML: html, htm
# - CSS: css
# - SCSS: scss
# - Less: less
# - Markdown: md, markdown
# - C#: cs
# - F#: fs, fsi, fsx
# - R: r, R
# - JSX: jsx
# - TSX: tsx (TypeScript React)

# Rust - rust-analyzer
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
args = []
file_patterns = ["**/*.rs"]
timeout_seconds = 30

# Uncomment and configure as needed:

# request_timeout_seconds = 30   # each LSP request behind a tool call
# indexing = "disabled"          # opt out of indexing-readiness gating

# [lsp_servers.initialization_options]
# cargo.features = "all"
# checkOnSave.command = "clippy"

# Settings pushed after `initialized` and served on workspace/configuration
# (must not be empty)
# [lsp_servers.settings]
# "rust-analyzer.check.command" = "clippy"

# Python - pyright
# [[lsp_servers]]
# language_id = "python"
# command = "pyright-langserver"
# args = ["--stdio"]
# file_patterns = ["**/*.py"]
# timeout_seconds = 30

# TypeScript/JavaScript - typescript-language-server
# selection = "auto" starts the native TypeScript 7 server (tsc --lsp --stdio)
# when it is installed; the command and args below are the fallback.
# [[lsp_servers]]
# language_id = "typescript"
# command = "typescript-language-server"
# args = ["--stdio"]
# selection = "auto"
# file_patterns = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
# timeout_seconds = 30

# Go - gopls
# [[lsp_servers]]
# language_id = "go"
# command = "gopls"
# args = ["serve"]
# file_patterns = ["**/*.go"]
# timeout_seconds = 30

# C/C++ - clangd
# [[lsp_servers]]
# language_id = "c"
# command = "clangd"
# args = ["--background-index"]
# file_patterns = ["**/*.c", "**/*.h", "**/*.cpp", "**/*.hpp"]
# timeout_seconds = 30

# Explicit per-tool routing: run two servers for one language, each owning
# a distinct subset of MCP tools. `name` gives each server a routing
# identity (defaults to language_id, but two servers for one language must
# not share it); `handles` restricts a server to exactly those tools --
# omit `handles` for a catch-all that serves everything not claimed
# elsewhere. See https://bug-ops.github.io/mcpls/reference/config.html for the full semantics.
# [[lsp_servers]]
# name = "pyright"
# language_id = "python"
# command = "pyright-langserver"
# args = ["--stdio"]
# file_patterns = ["**/*.py"]
#
# [[lsp_servers]]
# name = "pylsp"
# language_id = "python"
# command = "pylsp"
# file_patterns = ["**/*.py"]
# handles = ["diagnostics"]

What’s Next

Learn what each key does in the Configuration Reference, or return to Configuration for a guided walkthrough.

Benchmarks

mcpls-bench (crate crates/mcpls-bench, publish = false) measures mcpls latency end to end: it spawns a real mcpls process, speaks MCP over stdio, and lets mcpls drive a real language server on an open-source repository pinned to an exact commit. Every timed call is checked for a correct answer, so a fast wrong result is recorded as incorrect, not as a good sample. The same scenarios can be run against comparison MCP servers (Serena, lsmcp) through a target definition.

Unix (macOS, Linux) is the supported platform. Windows builds and uses a Job Object for the process tree and PowerShell/CIM for memory, but that path is compile-checked only: it is not run in CI and has not been exercised on a Windows machine.

Status: no performance claims are made in the README. Numbers are published only after they are reproduced on a second machine. Results are not committed to this repository.

Quick start

cargo build --release -p mcpls -p mcpls-bench

# Untimed: clone the pinned commit and run the scenario's setup steps
target/release/mcpls-bench prepare crates/mcpls-bench/scenarios/fd-rust-analyzer.toml

# Timed
target/release/mcpls-bench run crates/mcpls-bench/scenarios/fd-rust-analyzer.toml \
    --output fd-result.json

run prints the full JSON report to stdout and a summary table to stderr. --mcpls selects the mcpls binary; by default it is the mcpls next to mcpls-bench, never one found on PATH; a --mcpls value is always a file path, so a bare mcpls means ./mcpls. Rebuild mcpls yourself (cargo build -p mcpls) before a run: building mcpls-bench does not rebuild it. The report records the binary’s path, version, inferred build profile (debug/release from its directory name), size and modification time.

See Scenarios for the toolchain each scenario needs.

Useful flags: --runs (default 3), --warmup-runs (1), --iterations (5 per probe per run), --ready-timeout-secs (300), --call-timeout-secs (60), --work-dir (relative paths are resolved against the current directory), --allow-version-mismatch, --stderr-log-max-mib (32), --target (a comparison server instead of mcpls, see Comparison targets).

Methodology

  • Fresh process per run. Each run spawns mcpls, which spawns the language server, and shuts both down afterwards. mcpls leads its own process group (a Job Object on Windows). Shutdown is bounded: 10 s for mcpls to exit, then up to LIFELINE_SWEEP_BUDGET + 1 s (9 s) for the rest of its tree to follow, polled every 200 ms. The budget is that of the per-server watchdogs, which sweep the servers’ process groups after mcpls exits, so a watchdog that is still sweeping is not mistaken for a leak. The outcome is clean, killed (mcpls had to be killed, or the session did not close cleanly) or orphans_killed (mcpls exited but members of its tree outlived the budget and were killed). orphans_killed takes precedence over killed: it does not say whether the session had closed cleanly.
  • Process tree. Since the language servers sit in their watchdog’s process group, and helpers such as rust-analyzer’s flycheck call setsid, group membership alone no longer covers them. The harness reads the whole process table (ps -A on Unix, Get-CimInstance Win32_Process on Windows) and takes the parent-pid closure of mcpls: that is what RSS sums and what shutdown checks. Every process seen at a checkpoint (and once more right before shutdown) is remembered by pid plus start time (lstart on Unix, CreationDate on Windows), so a survivor is a process with the same pid and the same start time, never merely the same pid. Survivors are killed on Unix after re-verifying that identity against a fresh table; nothing is ever killed by a bare pid from an old snapshot. On Windows a parent link counts only if the parent started before the child (the recorded parent pid goes stale and is recycled), nothing is killed by pid, and the Job Object kills the tree.
  • Interruption. Ctrl-C or SIGTERM drops the active process-group guard, which kills the group of the active run before exiting with 130 or 143. prepare runs its clone and setup steps in their own process group too, so an interrupted pnpm install or cargo check leaves no grandchild behind; an interrupted clone leaves at most a repos/.<name>.partial directory, which the next prepare wipes. Descendants that left the group are swept by the servers’ watchdogs when mcpls dies, not by the harness.
  • Process-tree limits. A zombie is ignored. A descendant whose parent died before a sample is not in the closure (it has been reparented), so RSS can miss a helper that outlived its parent between checkpoints; it is still found by identity at shutdown if it was seen earlier. The PowerShell sampling used on Windows costs hundreds of milliseconds per checkpoint.
  • Concurrency. An exclusive lock on <work-dir>/locks/<scenario>.lock is held for the whole of prepare and run (flock on Unix, a zero share mode on Windows, retried for about 2 s there so an antivirus scan does not look like a second holder). A second invocation for the same scenario fails fast, because prepare stages in the shared repos/.<name>.partial directory and run shares configs and logs. Different scenarios do not block each other.
  • mcpls stderr is piped through a capped copy into <work-dir>/logs/<scenario>/<invocation unix millis>/run-<index>.log. The first --stderr-log-max-mib MiB (default 32) are written; the rest is read and dropped so mcpls never blocks on a full pipe, and a truncation marker is appended. After shutdown the copy gets 2 s to see end-of-stream and is then abandoned. The report records stderr_log per run as { path, written_bytes, dropped_bytes, drain_complete, error }; later invocations never overwrite earlier logs.
  • Timed vs untimed. Cloning, cargo fetch, cargo check (warms target/ and proc macros) and pnpm install happen in prepare and are never timed.
  • Regions.
    • startup: spawn until the MCP initialize handshake completes.
    • ready: spawn until the scenario’s ready_probe first passes.
    • hover, definition, references, document_symbols, diagnostics: latency of one tool call, repeated --iterations times per run.
  • Readiness is semantic. ready_probe is a normal probe with an expected answer (for example hover text containing a symbol name). It is retried every 50 ms until it passes (params.ready_retry_interval_ms); the attempt count is recorded. If it never passes within --ready-timeout-secs the ready sample is timed_out, the last failing attempt is kept in ready.last_failure, and the run’s remaining probes are skipped. A comparison target that cannot answer the scenario’s ready probe uses the first probe it can answer instead.
  • Fail fast. Permanent errors during readiness (invalid params such as a wrong file path or position outside the file, a closed transport) end the wait immediately. Every other failure is retried, and the last one is kept in the report (ready.last_failure). If a run never becomes ready, the remaining runs are skipped and the report is marked aborted.
  • Warm-up runs (--warmup-runs, default 1) are kept raw, marked "warmup": true, and excluded from the summary. The first run also warms the disk (page cache, target/), so results are warm-disk numbers.
  • First iteration vs steady state. Every probe runs --iterations times per fresh process. Iteration 0 pays one-off costs (document open, lazy initialisation) and is summarised separately (first); iterations 1 and up (steady) are warm latencies. Set --iterations to at least 2 to get a steady-state figure. startup and ready occur once per run and are reported under first.
  • Timeouts. After a call times out, mcpls is still serving it, so later calls would queue behind it and be inflated. The remaining probes of that run are skipped and the run is marked truncated_after_timeout.
  • Summary is min / lower median / p95 / max over successful samples of the measured runs; failed, incorrect, unsupported and timed-out samples are counted separately (not_ok, except unsupported ones, which get their own unsupported count so a capability gap does not look like a regression). p95_us is the nearest-rank observed value and is null (printed as -) below 20 samples, where it would equal the maximum. With the defaults a probe has 12 steady samples, so p95 appears only with more --runs or --iterations.
  • Process memory (RSS) is sampled from the process table at two checkpoints, ready (right after the ready probe passed) and after_probes, never during a call. The ready reading adds one process-table read (tens of milliseconds of idle time) between the ready probe and the first probe, during which the server may keep indexing, so first latencies are slightly flattered compared with no sampling. after_probes is skipped for a run truncated by a timeout. Each reading lists every member of the mcpls process tree (see above) and their sum (memory per run, memory_summary min / median / max of the sums). Pages shared between processes are counted more than once. RSS is unavailable where the process table cannot be read or mcpls is no longer in it.

Pinning and reproducibility

Only the repository commit is enforced. Tool versions (language server, toolchains, mcpls) are recorded in the report, and enforced only when a scenario opts in with expected_version; none of the bundled scenarios does.

  • Repositories are pinned to a full commit SHA (RepoSource::Git). prepare fetches exactly that commit and refuses a checkout at any other commit; run re-checks git rev-parse HEAD against the pin and reports the observed commit.
  • prepare writes a marker (outside the repository) after all setup steps succeed. run refuses to start without a marker matching the scenario’s current setup steps, so untimed work (cargo fetch, pnpm install) cannot leak into the timed ready region.
  • Executables are resolved to an absolute path via PATH (relative and empty PATH entries are ignored; on Windows PATHEXT extensions are tried, so pnpm finds pnpm.cmd), then their version command runs with the repository as the working directory (mcpls spawns the server there, so a rustup proxy picks the same toolchain). A server without a version flag of its own names a separate version_command (pyright-langserver uses pyright --version). The resolved path and version output are recorded in the report (target, runtime).
  • A scenario may set expected_version (a substring of the version output) per executable. A mismatch aborts the run unless --allow-version-mismatch is passed; the report’s pin record then shows the mismatch (version_output does not contain expected_version).
  • The work directory (default <cache dir>/mcpls-bench) must not be inside a project: an ancestor Cargo.toml makes cargo treat the clone as a workspace member (rust-analyzer would fail to load it), and an ancestor pnpm-workspace.yaml or node_modules changes how pnpm and TypeScript resolve it. The ancestors are checked before anything is created, so a refused path leaves no directory behind.
  • Clones are staged in repos/.<name>.partial and renamed into place after HEAD verifies, so an interrupted clone never looks complete. A leftover incomplete checkout from an older version (a .git without a resolvable HEAD) is removed and cloned again; a checkout at a wrong commit still aborts.
  • Git is hardened: scenario URLs must be https://github.com/<owner>/<repo> in canonical form (no credentials, port, query, fragment, whitespace, backslash, .. or upper-case host, so git and the URL parser read it the same way). Every git call removes every inherited GIT_* environment variable, ignores user and system configuration (GIT_CONFIG_GLOBAL is the null device, so insteadOf rewrites and a global http.proxy do not apply; proxies set through environment variables still do), allows only the https protocol, and puts -- before positional arguments of remote add and fetch.
  • RepoSource::Local (used only by the smoke scenario) is unpinned and reported as such.

Trust model

A scenario is code. setup commands run as your user, and cargo check, rust-analyzer and pnpm install execute build scripts and proc macros of the pinned repository (TypeScript scenarios use --ignore-scripts). Run only scenarios and repositories you trust. A comparison target is code too: its definition names the command that is launched. Reports contain absolute local paths (including your home directory) and tool versions; review them before publishing.

Scenarios

Every scenario pins its repository to a full commit. Validated means the scenario was run end to end on the machine that wrote it; the others have only had their probe positions and expected answers checked against the pinned sources, because the language server was not installed there.

ScenarioRepositoryServerNeedsValidated
fd-rust-analyzersharkdp/fd 10.5.0rust-analyzercargo, rust-analyzernot re-run here
react-hook-form-tslsreact-hook-form 7.69.0typescript-language-serverpnpm, node, typescript-language-server, typescriptyes
react-hook-form-tsgoreact-hook-form 7.69.0tsgo --lsp --stdiopnpm, node, tsgo (@typescript/native-preview)no
httpx-pyrightencode/httpx 0.28.1pyright-langserverpyrightprobes only (see below)
httpx-tyencode/httpx 0.28.1ty servertyno
cobra-goplsspf13/cobra 1.10.2goplsgo, goplsno
fmt-clangdfmtlib/fmt 12.2.0clangdclangd; setup writes compile_flags.txt (no CMake)yes
zls-zig-argsMasterQ32/zig-argszlszig, zls of matching versionsno
mcp-typescript-sdk-tslsmodelcontextprotocol/typescript-sdk 2.3.0typescript-language-serverpnpm, node, typescript-language-serverno
vscode-tslsmicrosoft/vscode 1.140.0typescript-language-servernpm, node, typescript-language-server; several GB of diskno
smoke-fixturein-repo fixturerust-analyzerrust-analyzeryes

Notes:

  • vscode-tsls and mcp-typescript-sdk-tsls are the scale scenarios. Run vscode-tsls with a long --ready-timeout-secs (for example 1800) and on dedicated hardware; it is not part of the scheduled workflow.
  • httpx-pyright: through mcpls every request to pyright-langserver 1.1.408 timed out after 60 s because mcpls never sent workspace/didChangeConfiguration, so pyright waited for a configuration push and blocked every request until shutdown. That bug is fixed (#578), but the scenario has not been re-run end to end since, so it is still unvalidated through mcpls.
  • fmt-clangd: clangd reports out-of-line members under their qualified name, so the symbol probe asks for buffered_file::close.
  • The symbol field of a hover, definition or references probe is the identifier at the position. mcpls ignores it; comparison targets that address symbols by name use it.
  • cobra-gopls and zls-zig-args carry no diagnostics probe, because pull-diagnostics support of those servers has not been verified.

Comparison targets

--target <file> replaces mcpls with another MCP server that is driven by the same scenario (repository, probes, readiness). The target brings its own language servers, so the scenario’s server is neither used nor pinned; the report’s target is external and records the launcher’s path and version, the pinned version and verification: textual.

A target definition (crates/mcpls-bench/targets/*.toml) names:

  • pinned: a version or commit that must appear in a launch argument, so the launch cannot drift (checked when the file loads);
  • launcher (the executable, version-recorded) and args, where "{repo}" is replaced by the absolute repository path;
  • cleanup: repository-relative paths the target writes. They are removed before and after every run, so one run never sees another’s state;
  • [tools.<kind>] for hover, definition, references, document_symbols and diagnostics: the MCP tool, its arguments, and for references and diagnostics a count_marker. An argument value is a constant or exactly one placeholder: {repo}, {file}, {relative_file}, {line1}, {line0}, {character1}, {character0}, {symbol}. Anything else in braces is an error.

A probe kind without a binding, or one needing a position or symbol the probe lacks, is recorded as unsupported (one sample, no call). Right after startup tools/list must contain every bound tool, or the run fails at once.

Answers are checked as text: hover.contains, definition.uri_suffix and document_symbols.symbol as substrings, references.min_count and the diagnostics expectations as occurrences of count_marker. That is weaker than the typed checks used for mcpls, so a comparison says “answered with the expected substring”, not “answered identically”.

TargetLaunchPinNotes
serenauvx --from git+https://github.com/oraios/serena@<sha> serena start-mcp-server with the ide contextv1.7.0web dashboard, browser and GUI log window are switched off by flags; .serena/ is cleaned before and after every run; no hover tool, so hover is unsupported
lsmcpnpx -y @mizchi/lsmcp@0.10.0 -p typescript0.10.0.lsmcp/ is cleaned; the reference count is approximate (.ts: occurrences); TypeScript scenarios only

Both targets pin only the top-level package or commit. Their transitive dependencies (Python packages resolved by uvx, npm packages resolved by npx) float, so two runs on different days can differ in more than the pinned version.

Both were run once against react-hook-form-tsls on the machine that wrote them, to check the flow; no numbers are kept or published. lsmcp did not exit on its own after stdin closed, so those runs end killed.

Scheduled run

.github/workflows/bench.yml runs weekly (Sunday 04:00 UTC) and on demand (workflow_dispatch) on ubuntu-latest with read-only permissions: it builds release binaries, runs prepare and run for fd-rust-analyzer and react-hook-form-tsls (5 runs, 1 warm-up, 10 iterations), and uploads each JSON report as a workflow artifact for 30 days. Nothing is published or committed. Shared CI runners are noisy: use these reports to spot regressions in memory and shutdown behaviour, not to quote latencies.

Publishing results

No results are published yet. A result may be quoted or added to the README only when it was produced by this procedure on two different machines:

  1. Build mcpls and mcpls-bench in release mode from the same commit on both machines, and use the same pinned scenario commits.
  2. Run the same scenario with the same --runs and --iterations on each machine; use at least 2 iterations and enough runs for p95_us to appear.
  3. Keep each JSON report. Its host record (os, arch, available_parallelism) identifies the machine; available_parallelism is what the process may use, which an affinity mask or a cgroup limit can set below the logical CPU count. The report does not record the CPU model or the amount of RAM: write them down by hand next to the numbers.
  4. Review each report for absolute local paths (see Trust model) before sharing it.
  5. Publish figures only where both machines agree on the ordering of the results, and state both machines.

Not done yet

The harness, scenarios, targets and bench.yml exist, but the following have not been run or published. They are tracked in #592:

  • reproduction of any result on a second machine, published results and a speed section in the README;
  • full runs of the scenarios marked “no” above and of httpx-pyright through mcpls;
  • scheduled runs on Windows and macOS, and a vscode-tsls scale run on dedicated hardware;
  • published Serena and lsmcp comparison results, and running the Windows path on a Windows machine.

Push-only servers

typescript-language-server (5.1.3) does not advertise pull diagnostics, so the TypeScript scenarios have no diagnostics probe. Add diagnostics probes only for servers that support pull diagnostics (rust-analyzer does).

Adding a scenario

Create a TOML file under crates/mcpls-bench/scenarios/; its name must equal the file stem:

name = "my-scenario"                     # [a-z0-9-]+, also the clone directory
setup = [{ command = "cargo", args = ["fetch"] }]

[source]
kind = "git"                             # or "local" with `path`
url = "https://github.com/owner/repo"
commit = "<40-char sha>"

[server]
language_id = "rust"
file_patterns = ["**/*.rs"]
args = []

[server.executable]
command = "rust-analyzer"
version_args = ["--version"]
expected_version = "rust-analyzer 1.99"  # optional
# version_command = "pyright"            # optional: print the version with another command

[[runtime]]                              # recorded toolchains, optional
command = "cargo"
version_args = ["--version"]

[ready_probe]
kind = "hover"                           # hover | definition | references | document_symbols | diagnostics
file = "src/main.rs"                     # relative to the repo root
position = { line = 63, character = 18 } # 1-based
contains = "ExitCode"
symbol = "ExitCode"                      # optional, for comparison targets

[[probes]]
kind = "definition"
file = "src/main.rs"
position = { line = 63, character = 18 }
uri_suffix = "src/main.rs"

Expectations per kind: hover.contains, definition.uri_suffix, references.min_count, document_symbols.symbol, diagnostics.expect = { kind = "no_errors" } or { kind = "at_least", count = N }. A references probe should sit on a symbol that is defined in its file, because comparison targets look references up by the file that defines the symbol.

Output schema

The JSON report is mcpls_bench::report::RunReport: scenario, source (pinned commit or unpinned path), target (mcpls with binary, build and server, or external), host (os, arch, available_parallelism), runtime pin records, params, runs (each with warmup, ready, truncated_after_timeout, samples, memory, stderr_log, shutdown), aborted, summary (per region: ok, not_ok, unsupported, first and steady min/median/p95/max) and memory_summary. A sample is { region, outcome, elapsed_us, iteration } where outcome is ok, incorrect { detail }, failed { error }, timed_out or unsupported.

Smoke test

crates/mcpls-bench/tests/smoke.rs runs the harness once against the in-repo Rust fixture (no network, no setup) and asserts that every sample is ok. It is #[ignore]d because it needs a built mcpls and rust-analyzer; the e2e CI job runs it. It keeps the harness in step with changes to MCP tool output shapes.