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.
| Part | Read it when you want to | Outcome |
|---|---|---|
| Part 1: Quick Start | Try mcpls | A working setup and your first answer in minutes |
| Part 2: Everyday Use | Use it on real projects | Configured servers, a tour of every tool, and fixes for common problems |
| Part 3: Under the Hood | Understand or harden a deployment | Architecture, positions, lifecycle, HTTP transport, security model |
| Reference | Look something up | Every 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.
| Platform | Architecture | Archive |
|---|---|---|
| Linux | x86_64 | mcpls-x86_64-unknown-linux-gnu.tar.gz |
| Linux | aarch64 | mcpls-aarch64-unknown-linux-gnu.tar.gz |
| macOS | Intel | mcpls-x86_64-apple-darwin.tar.gz |
| macOS | Apple Silicon | mcpls-aarch64-apple-darwin.tar.gz |
| Windows | x86_64 | mcpls-x86_64-pc-windows-msvc.zip |
| Windows | ARM64 | mcpls-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:
| Language | Install | Executable |
|---|---|---|
| Rust | rustup component add rust-analyzer | rust-analyzer |
| Python | npm install -g pyright | pyright-langserver |
| TypeScript, JavaScript | npm install -g typescript-language-server typescript@6 | typescript-language-server |
| Go | go install golang.org/x/tools/gopls@latest | gopls |
| C, C++ | apt install clangd or brew install llvm | clangd |
| Zig | install zls from your package manager | zls |
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 --versionworks 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
- mcpls is connected to your client.
- A project that matches an installed language server. Rust needs no further setup; for other languages see Minimal Configuration.
Ask about a type
Open a project and ask:
What is the return type of
process_requestinsrc/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::Timeoutis 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:
lineandcharacter, both starting at 1.symbol_name, such asprocess_requestorParser::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.rsand explain whatcalculate_totaldoes.
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_totalcalled, and which functions call it?
get_referenceslists every usage across the workspace.prepare_call_hierarchyandget_incoming_callsshow 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_datatohandle_dataeverywhere and show me the plan.
prepare_renamechecks that the symbol can be renamed.rename_symbolreturns 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.
Navigate to the right implementation
Which types implement the
Storagetrait?
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
SessionManagerdefined?
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
- A connected client and an installed language server.
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:
| Language | Server | Starts when the workspace contains |
|---|---|---|
| Rust | rust-analyzer | Cargo.toml, rust-toolchain.toml |
| Python | pyright | pyproject.toml, setup.py, requirements.txt, pyrightconfig.json |
| TypeScript | typescript-language-server | package.json, tsconfig.json, jsconfig.json |
| Go | gopls | go.mod, go.sum |
| C/C++ | clangd | CMakeLists.txt, compile_commands.json, Makefile, .clangd |
| Zig | zls | build.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:
- The path given with
--config(or theMCPLS_CONFIGenvironment variable). ./mcpls.tomlin the current directory, only when you pass--trust-project-config.- The user config directory:
| Platform | Location |
|---|---|
| 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
- You know where the file lives (Minimal Configuration).
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"]
commandis looked up onPATH; an absolute path also works.argscarries flags. Many servers need--stdio.file_patternsmaps files to this server. mcpls routes by extension (or by bare name for files such asMakefile), so only the final*.EXTpart 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
| Key | Controls | Default |
|---|---|---|
timeout_seconds | The initialize handshake at startup (1 to 900) | 30 |
request_timeout_seconds | Each LSP request behind a tool call (1 to 900) | 30 |
workspace.indexing_ready_timeout_seconds | How long whole-workspace queries wait for the server to finish indexing (above 3, below 60) | 30 |
workspace.max_documents | Files held open at once; the least recently used is closed first (0 = unlimited) | 100 |
workspace.max_file_size | Largest file mcpls opens, in bytes (0 = unlimited, at most 1 GiB) | 10 MiB |
workspace.max_concurrent_server_starts | Servers starting at the same time | 8 |
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
- mcpls installed and a configuration file if you go beyond the defaults.
Built-in servers
These start with no configuration when their project markers are present:
| Language | Server | Install |
|---|---|---|
| Rust | rust-analyzer | rustup component add rust-analyzer |
| Python | pyright | npm install -g pyright |
| TypeScript, JavaScript | typescript-language-server | npm install -g typescript-language-server typescript@6 |
| Go | gopls | go install golang.org/x/tools/gopls@latest |
| C, C++ | clangd | apt install clangd, dnf install clangd, or brew install llvm |
| Zig | zls | your 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
| Task | Tools |
|---|---|
| Read and navigate code | get_hover, get_definition, get_references, get_completions, get_document_symbols, workspace_symbol_search, get_document_highlights, get_signature_help, get_inlay_hints |
| Diagnostics and formatting | get_diagnostics, get_cached_diagnostics, format_document, format_range, get_folding_ranges, get_selection_ranges |
| Refactor | prepare_rename, rename_symbol, get_code_actions |
| Hierarchies and navigation | go_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 servers | get_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:
| Parameter | Meaning |
|---|---|
symbol_name | A symbol defined in the file; may be qualified, such as Type::method or Type.method |
symbol_kind | Optional filter by kind (function, method, struct, …) or numeric LSP SymbolKind |
container | Optional 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:
resolution | Meaning |
|---|---|
ambiguous | Several symbols match; every candidate is listed with its position |
not_found | No symbol has that name |
not_defined_in_file | The name is an import or a plain reference; use workspace_symbol_search or a position |
position_unverified | The 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_workspaceis 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_degradedappears 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 returnedcharacteroffsets 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"
}
}
status | Meaning |
|---|---|
resolved | The innermost containing symbol, with name_path (outermost first), LSP numeric kind, range and fidelity |
top_level | The file’s symbols were read and none contains the item |
not_computed | Skipped, with a reason: file_cap, out_of_workspace, tracker_limit or deadline |
unavailable | Attempted 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:
| Situation | What to do |
|---|---|
| No server for the file’s language | Add an [[lsp_servers]] entry, or install the server |
-32602 invalid params | Fix 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_advertised | The 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
- You know how to address code.
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).
workspace_symbol_search
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
- You know how to address code.
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:
| Field | Values and meaning |
|---|---|
availability | published: 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 |
origin | pull 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_progress | true if the server was still indexing during the read, so early errors may be false and real ones may be missing |
push_notifications_degraded | true 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
- You know how to address code.
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:
status | Meaning |
|---|---|
renameable | The rename is valid; the result carries the identifier range and, when the server provides it, a placeholder |
default_behavior | The server accepts the rename but reports no range |
not_renameable | The 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
- You know how to address code.
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".
| Tool | Answers | Typical use |
|---|---|---|
go_to_implementation | Where is this trait method or interface member implemented? | Find implementors of a trait |
go_to_type_definition | What type does this expression have, and where is it defined? | Jump from a variable to its type, not to its binding |
go_to_declaration | Where 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.
prepare_call_hierarchytakes a position orsymbol_nameand returns callable items.get_incoming_callsreturns the callers of an item.get_outgoing_callsreturns 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:
prepare_type_hierarchytakes a position and returns type items.get_supertypesreturns the base classes and implemented interfaces of an item.get_subtypesreturns 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
- You know the tool conventions.
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:
status | Meaning |
|---|---|
supported | The call will be dispatched to a server that advertises the capability |
push_only | The server publishes diagnostics but has no pull provider, so get_diagnostics answers from the push cache |
capability_not_advertised | The server does not advertise the feature |
initializing | The server has not finished starting |
no_server | No 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:
status | Meaning |
|---|---|
restarted | A new process runs; indexing_state is unknown, loading or ready |
failed | The restart failed with a typed reason; the server stays registered and the next tool call retries |
throttled | Restarted too recently; retry after retry_in_ms |
initializing | The server is still starting |
not_running | The 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
- mcpls is installed (Installation).
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
- Run
mcpls --versionin a terminal. - Check the client’s configuration file for a syntax error. JSON does not allow comments or trailing commas.
- Restart the client completely.
- If the client cannot find the binary, use its absolute path as
command;which mcplsprints 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,commandor other required field. - A
file_patternsentry in an unsupported form, such as**/*.{ts,tsx},src/**or.eslintrc. - A
workspace.rootsentry that does not exist. - Two servers for one language without distinct
namevalues, 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
reason | Fix |
|---|---|
capability_absent | The server does not advertise documentSymbolProvider |
request_failed, timed_out | Retry, or check get_server_logs |
file_cap | Too many distinct files; narrow the query or raise workspace.max_documents |
tracker_limit | The file could not be opened; raise workspace.max_documents |
out_of_workspace | The file is outside every root and is never opened |
deadline | The 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:
| Crate | Role |
|---|---|
mcpls (mcpls-cli) | The binary: command-line parsing, logging, then a call into the library |
mcpls-core | The library that implements everything else |
Inside mcpls-core:
| Module | Responsibility |
|---|---|
config | Loads TOML, discovers servers by project markers, holds the trust types |
lsp | Spawns language servers and speaks JSON-RPC 2.0 to them; resolves which executable runs; pins tsserver |
mcp | The MCP server (built on the rmcp crate): tool definitions and dispatch |
runtime | Startup, supervision, shutdown, and the untrusted-workspace plan |
transport | stdio and HTTP serving |
bridge | The 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:
- Validate. The MCP layer checks the arguments and that
file_pathis under a workspace root. - Route. The router picks the language server for the file from its extension, honoring
handlesand falling back to a catch-all server if the preferred one failed to start. - Open the document. The bridge sends
textDocument/didOpenthe first time a file is touched, and re-syncs it if the file changed on disk. - Wait for readiness. For whole-workspace queries, mcpls waits (up to
indexing_ready_timeout_seconds) until the server reports it has finished indexing. - Resolve the name. A
symbol_nameis resolved throughtextDocument/documentSymbol, and the identifier position is verified in the text. - Convert the position. 1-based MCP coordinates become 0-based LSP coordinates in the encoding the server negotiated (Positions and Encodings).
- Request. The LSP request is sent, bounded by
request_timeout_seconds, and retried on “content modified”. - Translate back. The response becomes the tool result: positions are converted back, locations are flagged
out_of_workspacewhen 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
- You know the
lineandcharacterarguments (Tools Overview).
Two coordinate systems
| MCP tools (what the assistant sees) | LSP (what language servers use) | |
|---|---|---|
| First line | 1 | 0 |
| First column | 1 | 0 |
| Column unit | UTF-16 code units | The 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:
| Value | Meaning | What to do |
|---|---|---|
"request" | The position you sent reached the server unconverted, so the result may describe a different symbol | Do not trust the result |
"response" | Only returned character offsets may be inexact; the result describes the symbol you asked about | Keep 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
- You know what a language server is (Introduction).
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:
- Spawns the process with a cleared environment plus an allowlist of variables and the entry’s
env(Security and Trust). - Sends
initializewith the workspace folders, the position encodings it prefers, and theinitialization_optionsfrom the entry. The request is bounded bytimeout_seconds(default 30, at most 900). - Reads the server’s capabilities from the reply. Tool support is derived from these.
- Sends
initialized, and then the entry’ssettingswithworkspace/didChangeConfiguration.
4. Steady state
- Documents.
textDocument/didOpenis sent on first use of a file. Beyondworkspace.max_documents, the least recently used unlocked document is closed withdidClose. 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 toindexing_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, andindexing = "disabled"opts a server out. - Notifications. Diagnostics, log messages and
showMessagenotifications 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:
- It stops accepting work and cancels background tasks.
- For each server it sends the LSP
shutdownrequest (5 second timeout), thenexit. - 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
- You have used
get_diagnostics.
Push versus pull
LSP servers deliver diagnostics in two ways:
- Push. The server sends
textDocument/publishDiagnosticswhenever it has new results. Background analyzers such as rust-analyzer’s flycheck (clippy) work only this way. - Pull. The client sends
textDocument/diagnosticand 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_diagnosticsis stored next to the pushed entries in a separate slot. Partial andunchangedreports, 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
showMessagemessages (50 entries) are kept in separate bounded buffers forget_server_logsandget_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:
| Value | Meaning |
|---|---|
published | The server reported on the file; an empty list means clean |
pending | Nothing has been published since the server started; unknown |
evicted | A 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:
| Field | Meaning |
|---|---|
tracked | false (always with an empty list) when mcpls knows nothing about the file |
version | The document version the diagnostics were computed against, when known |
diagnostics | The LSP Diagnostic objects, with LSP’s own casing |
availability | As above |
indexing_in_progress, push_notifications_degraded | As 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_diagnosticspull 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
- You know how a client launches mcpls.
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.1or::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:
| Limit | Default | Effect |
|---|---|---|
| Request body | 4 MiB | Larger requests receive 413 |
| Header read and body-chunk pause | 30 s | A slow request head or body is answered with 408, and idle keep-alive connections close |
| Write stall | 30 s | A connection whose writes make no progress is closed, which frees a peer that stopped reading |
| Concurrent connections | 512 | Raise ulimit -n first on macOS, where launchd’s soft limit is 256 |
| Concurrent sessions | 100 | New sessions beyond the limit receive 429 |
| Session idle timeout | 5 minutes | A session with no inbound request expires |
| Response stream deadline | 1 hour | A 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
- You know where configuration comes from.
Two kinds of trust
- Trust in the mcpls configuration. A
mcpls.tomlnames the commands mcpls spawns, their environment and their options. Running a command from a file you did not write is code execution. - 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
| Server | Workspace-supplied code it can run |
|---|---|
| typescript-language-server | The workspace’s node_modules/typescript/lib/tsserver.js unless pinned; tsconfig plugins; automatic type acquisition may fetch packages over the network |
| rust-analyzer | Cargo build scripts and procedural macros |
| pyright, pylsp | The project’s Python environment, plugins and interpreters named in config |
| gopls | The Go toolchain, including toolchain downloads requested by go.mod |
| clangd | Commands 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’senv. Variables such asNODE_OPTIONSorLD_PRELOADare 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
tsserverinstead 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
| Situation | Recommendation |
|---|---|
| Your own project | Defaults are fine |
| A repository you trust but did not write | Review its mcpls.toml before using --trust-project-config |
| A repository you do not trust | Use a container or VM, and consider untrusted mode |
| A team-shared client config | Remember that a project-scoped .mcp.json is controlled by the repository |
| HTTP on a non-loopback address | Always 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
- You have read Security and Trust.
Turn it on
Untrusted mode is selected only on the command line:
mcpls --workspace-trust untrusted --allow-server rust --allow-server python
--workspace-trustistrusted(the default) oruntrusted.--allow-server <id>names a server to start. Repeat it for each server. The id is the server’sname, otherwise itslanguage_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.jsonin 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:
| Check | Rule |
|---|---|
| Configuration file | The 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 |
| Executable | It 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 |
PATH | The server gets a PATH with workspace, relative and empty entries removed, so an interpreter such as node cannot resolve into the workspace |
| Environment | Cleared, 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 |
| Launcher | A 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 directory | The server starts in your login home directory, else a system temporary directory that no other user can write to, never in the checkout |
| tsserver | A pin that resolves inside the workspace, or a launcher no tsserver can be pinned for, is refused |
| Windows | NoDefaultCurrentDirectoryInExePath=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.mjsruns workspace code; only thenodeexecutable is checked, unlesscli.mjshas 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.tomlwhosepathnames a toolchain inside it; asdf, mise and Volta pick versions from workspace files (refused only for the TypeScript server); Go switches toolchains fromgo.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
- You have read Security and Trust.
typescript-language-serveris installed (Language Servers).
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 -gstyle symlink installs;- npm
.cmd,.ps1and extensionless shims beside anode_modulesthat holds the server; - pnpm global installs, found under
<PNPM_HOME>/global; nodeorbunrunning the server’s absolutecli.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:
| Case | What to do |
|---|---|
Server not found on PATH | Install typescript-language-server or fix command |
| Started through a Volta, asdf or mise shim, or another wrapper script | Set 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 path | Use 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 entries | Remove stale entries, or set tsserver.path |
No valid typescript package next to the server | npm install -g typescript@6 |
Only TypeScript 7 or later next to the server, which ships no tsserver | Install typescript@6 next to the server, or use the native server below |
You set initialization_options without tsserver.path | Add tsserver.path to keep the pin |
A warning after startup that the server reports a version source other than user-setting | The 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 fortsconPATH. Only atypescript/bin/tscof a TypeScript 7 install is accepted, never one inside the workspace (symlinks included). - Otherwise
typescript-language-serveris kept, also whentscneedsnodeandnodeis not onPATH. The choice and its reason are logged at info level. - A TypeScript 6 install next to
typescript-language-serverwins over a TypeScript 7 one. - An
initialization_options.tsserver.pathyou set always keepstypescript-language-server. - When the native server is chosen, mcpls replaces the entry’s
commandandargs. Removeselectionto keep your own. - On Windows the npm or pnpm
tsc.cmdshim is mapped to itstypescriptpackage and started asnode <package>\bin\tsc --lsp --stdio, never throughcmd.exe, with the firstnode.exeonPATH. If thatnodeor 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
- You know how servers start and stop.
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
psandawk. Withoutps, 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 -9that matcheslsp-lifeline-watchdogkills the watchdog and prevents the sweep. - Servers that exit only on stdin EOF wait out the 3 second shutdown grace and are then killed.
processIdis sent asnullininitialize. - 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 bySIGTTINwhen 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-analyzeror 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_serveruses 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
- Familiarity with the configuration and the tool conventions.
Resource limits you can configure
| Limit | Default | Where to change it |
|---|---|---|
| Open documents | 100 (least recently used closed first) | workspace.max_documents |
| Largest file opened | 10 MiB (at most 1 GiB) | workspace.max_file_size |
| Servers starting at once | 8 | workspace.max_concurrent_server_starts |
| Wait for indexing | 30 s (above 3, below 60) | workspace.indexing_ready_timeout_seconds |
| Handshake timeout | 30 s (1 to 900) | timeout_seconds |
| Request timeout | 30 s (1 to 900) | request_timeout_seconds |
| Project-marker search depth | 10 (at most 64) | workspace.heuristics_max_depth |
mcp.title, description, instructions, tool_prefix | 128, 1024, 4096 and 32 bytes | [mcp] |
Limits that are fixed
| Limit | Value |
|---|---|
| Completion request | 10 s, whatever request_timeout_seconds says |
| Retries on “content modified” | 4 attempts, 3.5 s of total backoff |
shutdown request to a server | 5 s |
| Child exit grace after shutdown | 3 s |
restart_server cooldown | 5 s per server |
| Respawn backoff | 1 s growing to 30 s |
Server ids per restart_server call | 64 |
| Diagnostics cache | 1000 files |
| Server log buffer, server message buffer | 100 and 50 entries |
| Subscriptions per session | 1000 |
Concurrent subscriptions/listen streams | 100 |
| Enclosing-symbol enrichment | 16 files (or max_documents / 4 when max_documents is below 64), 30 s |
| Per-response disk read for position conversion | 4 times max_file_size, at most 256 MiB |
| Result lists | Capped 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_patternscannot confine a server to a directory, and two servers that claim the same extension both receive every file with it unlesshandlesseparates them. file_patternsforms. Only*.EXT(with an optional**/prefix) and bare extensionless names such asMakefileare accepted. Brace expansion, character classes,?, dotfiles, dotted names, single files and multi-part extensions such as*.tar.gzare rejected at startup.workspace_symbol_searchhas no document. It goes to the first server that claimsworkspace_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
datakeys, or tool results such as hover text (Diagnostics and Resources). - Platform notes: the Windows launch of the native TypeScript server and the Windows
USERPROFILEreplacement 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
| Flag | Environment variable | Default | Description |
|---|---|---|---|
-c, --config <FILE> | MCPLS_CONFIG | discovered | Path to the configuration file. A named path is always trusted |
--trust-project-config | MCPLS_TRUST_PROJECT_CONFIG | off | Load a ./mcpls.toml found in the current directory |
--workspace-trust <MODE> | none | trusted | trusted or untrusted; command line only |
--allow-server <ID> | none | none | Server to start in untrusted mode; repeatable; command line only |
-l, --log-level <LEVEL> | MCPLS_LOG | info | A level or comma-separated target=level directives |
--log-json | MCPLS_LOG_JSON | off | Output logs as JSON lines |
--listen <ADDR> (HTTP) | MCPLS_LISTEN | none | Serve Streamable HTTP on this address instead of stdio |
--http-path <PATH> (HTTP) | MCPLS_HTTP_PATH | /mcp | URL path the service is mounted at |
--http-stream-liveness <MODE> (HTTP) | MCPLS_HTTP_STREAM_LIVENESS | probe | probe or off |
--http-allowed-origin <ORIGIN> (HTTP) | MCPLS_HTTP_ALLOWED_ORIGINS | none | Extra browser origin; repeatable or comma-separated |
--http-allowed-host <HOST> (HTTP) | MCPLS_HTTP_ALLOWED_HOSTS | none | Extra Host value; repeatable or comma-separated |
-h, --help | none | Print help | |
-V, --version | none | Print 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:
| Platform | Location |
|---|---|
| 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
| Code | Meaning |
|---|---|
0 | Clean shutdown |
1 | A startup or runtime error, logged before exit |
2 | A command-line usage error |
101 | mcpls 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.
| Key | Overrides | Default | Maximum |
|---|---|---|---|
title | serverInfo.title | MCPLS - MCP to LSP Bridge | 128 bytes |
description | serverInfo.description | The crate description | 1024 bytes |
instructions | ServerInfo.instructions | Built-in capability text | 4096 bytes |
tool_prefix | Every tool name, as {tool_prefix}_{tool} | No prefix | 32 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
| Language | Extensions | Language ID |
|---|---|---|
| Rust | rs | rust |
| Python | py, pyw, pyi | python |
| JavaScript | js, mjs, cjs | javascript |
| TypeScript | ts, mts, cts | typescript |
| TypeScript React | tsx | typescriptreact |
| JavaScript React | jsx | javascriptreact |
| Go | go | go |
| C | c, h | c |
| C++ | cpp, cc, cxx, hpp, hh, hxx | cpp |
| Java | java | java |
| Ruby | rb | ruby |
| PHP | php | php |
| Swift | swift | swift |
| Kotlin | kt, kts | kotlin |
| Scala | scala, sc | scala |
| Zig | zig | zig |
| Lua | lua | lua |
| Shell | sh, bash, zsh | shellscript |
| JSON | json | json |
| TOML | toml | toml |
| YAML | yaml, yml | yaml |
| XML | xml | xml |
| HTML | html, htm | html |
| CSS | css | css |
| SCSS | scss | scss |
| Less | less | less |
| Markdown | md, markdown | markdown |
| C# | cs | csharp |
| F# | fs, fsi, fsx | fsharp |
| R | r, R | r |
[[lsp_servers]]
One table per language server.
| Key | Type | Default | Description |
|---|---|---|---|
language_id | string | required | Language identifier sent to the server |
command | string | required | Executable, on PATH or absolute |
args | array of strings | [] | Command-line arguments |
file_patterns | array of strings | [] | Files this server serves (forms) |
timeout_seconds | integer, 1 to 900 | 30 | The initialize handshake |
request_timeout_seconds | integer, 1 to 900 | 30 | Each LSP request behind a tool call |
initialization_options | table | none | Sent in initialize |
settings | table | none | Pushed after initialized |
env | table of strings | {} | Environment variables for the server |
heuristics | table | none | Project markers |
name | string | the language_id | Routing identity |
handles | array of strings | unset (catch-all) | Routing values this server serves |
indexing | "disabled" | unset | Opt 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, whereEXTis 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,
NAMEor**/NAME, such asMakefile. 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 value | Tools it governs |
|---|---|
hover | get_hover |
definition | get_definition |
type_definition | go_to_type_definition |
declaration | go_to_declaration |
implementation | go_to_implementation |
references | get_references |
diagnostics | get_diagnostics and get_cached_diagnostics |
rename | rename_symbol, prepare_rename |
completions | get_completions |
signature_help | get_signature_help |
document_symbols | get_document_symbols |
workspace_symbols | workspace_symbol_search |
format_document | format_document |
format_range | format_range |
code_actions | get_code_actions |
call_hierarchy | prepare_call_hierarchy, get_incoming_calls, get_outgoing_calls |
type_hierarchy | prepare_type_hierarchy, get_supertypes, get_subtypes |
document_highlights | get_document_highlights |
inlay_hints | get_inlay_hints |
selection_range | get_selection_ranges |
folding_range | get_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 exclusiveheuristicsmake 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_searchhas no language. It goes to the first server that claimsworkspace_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 isclean,killed(mcpls had to be killed, or the session did not close cleanly) ororphans_killed(mcpls exited but members of its tree outlived the budget and were killed).orphans_killedtakes precedence overkilled: 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 -Aon Unix,Get-CimInstance Win32_Processon 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 (lstarton Unix,CreationDateon 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-CorSIGTERMdrops the active process-group guard, which kills the group of the active run before exiting with 130 or 143.prepareruns its clone and setup steps in their own process group too, so an interruptedpnpm installorcargo checkleaves no grandchild behind; an interrupted clone leaves at most arepos/.<name>.partialdirectory, which the nextpreparewipes. 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>.lockis held for the whole ofprepareandrun(flockon 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, becausepreparestages in the sharedrepos/.<name>.partialdirectory andrunshares 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-mibMiB (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 recordsstderr_logper run as{ path, written_bytes, dropped_bytes, drain_complete, error }; later invocations never overwrite earlier logs. - Timed vs untimed. Cloning,
cargo fetch,cargo check(warmstarget/and proc macros) andpnpm installhappen inprepareand are never timed. - Regions.
startup: spawn until the MCPinitializehandshake completes.ready: spawn until the scenario’sready_probefirst passes.hover,definition,references,document_symbols,diagnostics: latency of one tool call, repeated--iterationstimes per run.
- Readiness is semantic.
ready_probeis 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-secsthereadysample istimed_out, the last failing attempt is kept inready.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 markedaborted. - 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
--iterationstimes 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--iterationsto at least 2 to get a steady-state figure.startupandreadyoccur once per run and are reported underfirst. - 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 ownunsupportedcount so a capability gap does not look like a regression).p95_usis the nearest-rank observed value and isnull(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--runsor--iterations. - Process memory (RSS) is sampled from the process table at two checkpoints,
ready(right after the ready probe passed) andafter_probes, never during a call. Thereadyreading 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, sofirstlatencies are slightly flattered compared with no sampling.after_probesis skipped for a run truncated by a timeout. Each reading lists every member of the mcpls process tree (see above) and their sum (memoryper run,memory_summarymin / median / max of the sums). Pages shared between processes are counted more than once. RSS isunavailablewhere 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).preparefetches exactly that commit and refuses a checkout at any other commit;runre-checksgit rev-parse HEADagainst the pin and reports the observed commit. preparewrites a marker (outside the repository) after allsetupsteps succeed.runrefuses to start without a marker matching the scenario’s current setup steps, so untimed work (cargo fetch,pnpm install) cannot leak into the timedreadyregion.- Executables are resolved to an absolute path via
PATH(relative and emptyPATHentries are ignored; on WindowsPATHEXTextensions are tried, sopnpmfindspnpm.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 separateversion_command(pyright-langserver usespyright --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-mismatchis passed; the report’s pin record then shows the mismatch (version_outputdoes not containexpected_version). - The work directory (default
<cache dir>/mcpls-bench) must not be inside a project: an ancestorCargo.tomlmakes cargo treat the clone as a workspace member (rust-analyzer would fail to load it), and an ancestorpnpm-workspace.yamlornode_moduleschanges 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>.partialand renamed into place after HEAD verifies, so an interrupted clone never looks complete. A leftover incomplete checkout from an older version (a.gitwithout 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 inheritedGIT_*environment variable, ignores user and system configuration (GIT_CONFIG_GLOBALis the null device, soinsteadOfrewrites and a globalhttp.proxydo not apply; proxies set through environment variables still do), allows only the https protocol, and puts--before positional arguments ofremote addandfetch. 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.
| Scenario | Repository | Server | Needs | Validated |
|---|---|---|---|---|
fd-rust-analyzer | sharkdp/fd 10.5.0 | rust-analyzer | cargo, rust-analyzer | not re-run here |
react-hook-form-tsls | react-hook-form 7.69.0 | typescript-language-server | pnpm, node, typescript-language-server, typescript | yes |
react-hook-form-tsgo | react-hook-form 7.69.0 | tsgo --lsp --stdio | pnpm, node, tsgo (@typescript/native-preview) | no |
httpx-pyright | encode/httpx 0.28.1 | pyright-langserver | pyright | probes only (see below) |
httpx-ty | encode/httpx 0.28.1 | ty server | ty | no |
cobra-gopls | spf13/cobra 1.10.2 | gopls | go, gopls | no |
fmt-clangd | fmtlib/fmt 12.2.0 | clangd | clangd; setup writes compile_flags.txt (no CMake) | yes |
zls-zig-args | MasterQ32/zig-args | zls | zig, zls of matching versions | no |
mcp-typescript-sdk-tsls | modelcontextprotocol/typescript-sdk 2.3.0 | typescript-language-server | pnpm, node, typescript-language-server | no |
vscode-tsls | microsoft/vscode 1.140.0 | typescript-language-server | npm, node, typescript-language-server; several GB of disk | no |
smoke-fixture | in-repo fixture | rust-analyzer | rust-analyzer | yes |
Notes:
vscode-tslsandmcp-typescript-sdk-tslsare the scale scenarios. Runvscode-tslswith 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 sentworkspace/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 forbuffered_file::close.- The
symbolfield 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-goplsandzls-zig-argscarry nodiagnosticsprobe, 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) andargs, 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>]forhover,definition,references,document_symbolsanddiagnostics: the MCPtool, itsarguments, and forreferencesanddiagnosticsacount_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”.
| Target | Launch | Pin | Notes |
|---|---|---|---|
serena | uvx --from git+https://github.com/oraios/serena@<sha> serena start-mcp-server with the ide context | v1.7.0 | web 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 |
lsmcp | npx -y @mizchi/lsmcp@0.10.0 -p typescript | 0.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:
- Build
mcplsandmcpls-benchin release mode from the same commit on both machines, and use the same pinned scenario commits. - Run the same scenario with the same
--runsand--iterationson each machine; use at least 2 iterations and enough runs forp95_usto appear. - Keep each JSON report. Its
hostrecord (os,arch,available_parallelism) identifies the machine;available_parallelismis 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. - Review each report for absolute local paths (see Trust model) before sharing it.
- 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-pyrightthrough mcpls; - scheduled runs on Windows and macOS, and a
vscode-tslsscale 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.