Skip to content

Security: bug-ops/mcpls

SECURITY.md

Security Policy

Supported versions

Only the latest released minor version of mcpls receives security fixes.

Reporting a vulnerability

Report vulnerabilities privately through GitHub private vulnerability reporting: open the repository's Security tab and choose Report a vulnerability (https://github.com/bug-ops/mcpls/security/advisories/new). Do not file a public issue for an undisclosed vulnerability.

Include the affected version, the language server involved, and a minimal reproduction. Reports are acknowledged on a best-effort basis.

Trust model

mcpls bridges an AI client to language servers that analyze a workspace. Analyzing a workspace is not a safe operation for an untrusted workspace, and mcpls does not sandbox the servers it spawns.

  • Workspace-supplied mcpls config. A project-local ./mcpls.toml can name server commands and environment, and set per-server settings and initialization_options, which can steer what a server executes (for example pyright's interpreter or rust-analyzer's check.overrideCommand). It is ignored unless you pass --trust-project-config. This flag governs only the mcpls config. It does not make the workspace itself safe to analyze.

  • Language servers execute workspace code. This is inherent to LSP and the same for every LSP bridge. Pointing mcpls at an untrusted checkout (a cloned third-party repository, a pull-request branch) can run that checkout's code through the servers mcpls auto-spawns:

    Server Workspace-supplied code the server can run
    typescript-language-server node_modules/typescript/lib/tsserver.js from the workspace, unless pinned (see below); tsconfig plugins when the workspace tsserver runs (a pinned tsserver does not load them from the workspace); 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 other rows describe the well-known behavior of those servers.

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

    • npm -g style symlink installs (verified with Homebrew's node);
    • npm .cmd, .ps1 and extensionless shims beside a node_modules that holds the server (verified live only for the Unix shell shim; the Windows layouts are covered by unit tests);
    • pnpm global installs (verified live on macOS; the Windows layout is covered by unit tests), found under <PNPM_HOME>/global, where more than one match or more than 16 entries is ambiguous and not pinned;
    • node, nodejs or bun (also versioned, such as node22) running the server's absolute cli.mjs.

    Shims are located by file existence only and are never executed or parsed. On Windows a bare command is looked up as written, so an install that only provides typescript-language-server.cmd needs that name as command. It is not applied, and a warning is logged, when:

    • the server is started through Volta, asdf, mise or another version-manager shim;
    • the server is started through npx, bunx, pnpm dlx, yarn dlx, deno npm: or another wrapper that only names it in the arguments, including node, nodejs or bun (also versioned, such as node22) with a relative script path;
    • no valid typescript package is found next to the server;
    • you set initialization_options for the server without tsserver.path.

    Version-manager shims are not resolved because their install directory can only be found through the manager's own layout, and asdf, mise and Volta pick the version from workspace files; set initialization_options.tsserver.path to pin them. In untrusted-workspace mode the first two cases are refused instead of started with a warning (see below).

    A server installed inside the workspace is pinned, but that pins the workspace's own typescript and the server itself is workspace code, so nothing is narrowed. A tsserver.path you set yourself always wins; set it to a workspace path to opt back in to the workspace's TypeScript. The pin is resolved once per start, and the path that is checked against the workspace is the canonical path that is sent. A respawn resolves it again when the pinned path no longer resolves to the same file, with the same check in untrusted mode. The pin narrows one vector. It does not make an untrusted workspace safe.

  • Automatic native tsc selection. An entry with selection = "auto" (the generated TypeScript default) may start tsc --lsp --stdio from a TypeScript 7 install. mcpls accepts only a typescript/bin/tsc that canonicalizes to a path outside every workspace root, so a workspace node_modules is never selected. On Windows the npm tsc.cmd shim is mapped to its package and the server starts as node <package>\bin\tsc --lsp --stdio, never through cmd.exe. That node is the first node.exe on the server's effective PATH and passes the same check: if it or the PATH directory it was found in lies inside a workspace root, the TypeScript language server is kept instead. The native server loads no tsconfig plugins (checked live). This does not make an untrusted workspace safe. Remove selection to run command as written.

  • Workspace file reads check the opened file. A document a tool opens is checked through the handle that was opened, not through the path validated earlier, so replacing a parent directory with a symlink out of the roots between the check and the open does not read the target. Linux and Android read /proc/self/fd/<fd> of the handle, macOS asks the handle for its path (F_GETPATH); the result must lie inside a root. Without /proc a read fails with an I/O error and one warning is logged (it fails closed). A file reached through a hardlink named inside the roots is admitted, as its other names are not examined. The reported path is compared with the roots by exact case (macOS reports the on-disk spelling, so opening a file through another spelling of its name does not change the result). Windows has no safe handle-to-path call, so every directory from the root to the file's parent is held open without delete sharing while the file is opened, a symlink or junction among them is refused, and the final entry is opened once as itself (FILE_FLAG_OPEN_REPARSE_POINT, never its target), so a symlink or junction there is refused without opening what it points to. A final entry that is a reparse point but not a link (WOF compression, deduplication, cloud placeholders) is pinned without delete sharing, opened again normally and compared by file identity before and after. Residual on Windows: during that check (milliseconds) other tools cannot rename or remove an ancestor directory of the file, such as git checkout dropping a directory, an IDE move or a rename of the root; the final entry itself may be renamed meanwhile, which cannot lead outside because the handle already is the entry, except in the reparse-point case above, where the pin blocks it. In that case the pin does not stop the file's reparse data being rewritten in place (same file id) into a symlink to an outside device or named pipe; the reopen then opens that target before the identity check refuses it. It needs a workspace writer and a tiny window, and affects only such files, not ordinary ones; a hardlink there names a file that is already reachable and is admitted; SMB or FUSE volumes that report a zero file index compare equal (fail-open, only in the reparse-point case), and ReFS 128-bit indexes are truncated. On Linux the file is opened with openat2 confined beneath the root (no symlink or magic link is followed, so an outside target is never opened), falling back to a per-component O_NOFOLLOW walk where openat2 is unavailable; other Unix systems use that walk, which refuses a symlink in any component. On Linux and macOS a swap of the roots themselves or of the directories above a root is caught by the handle-path check; on Windows and other Unix systems it is not covered (on other Unix a directory moved out of the roots after the walk entered it is not detected). macOS uses the same walk, so an outside FIFO or device behind a symlink is never opened. The handle-path check then runs on the opened file. A directory on the path with search-only permission cannot be opened for the walk, and the open fails. On Windows a directory below a root that is a junction or a volume mounted in a folder is a reparse point and is refused.

  • Save notifications. While a file of a server is subscribed, mcpls sends textDocument/didSave to that server, from the diagnostics sweep only, for each tracked file it sees changed on disk (at most 64 subscribed and 32 unsubscribed files per 2 s tick), when the server advertises textDocumentSync.save (the client capabilities declare synchronization.didSave). Servers then re-run their on-save checks, such as rust-analyzer's build scripts and procedural macros, after each edit. The risk class (workspace code the server runs) already existed; the frequency is new. Dynamic didSave registration is not tracked. In untrusted-workspace mode the sweep never sends didSave. A didSave follows a content digest, so only a file whose text changed sends one.

  • Explicitly configured workspace binaries. A server command you configure, for example a TypeScript 7 tsc for the native server (tsc --lsp --stdio), is run as written. A tsc from the workspace's node_modules, or one that PATH resolves into the workspace, is workspace-supplied code. The tsserver pin does not apply to it.

Untrusted-workspace mode

--workspace-trust untrusted starts only the language servers you name with --allow-server <id> (repeatable; the id is the server's name, else its language_id, such as rust). Every other applicable server is refused before it is spawned, restarted or respawned, and a tool call routed to it returns an error that names the server and the flag that would start it. The flags are command-line only: there is no environment variable and no config key, so a config file planted in the workspace cannot grant consent. The mode conflicts with --trust-project-config (also when set by MCPLS_TRUST_PROJECT_CONFIG), and --allow-server without it is a usage error (exit code 2).

This is not a sandbox. An allowed server still runs the workspace code listed above, so allow a server only when you accept that. The mode additionally enforces the following, and nothing more:

  • Config file. The file that was actually loaded must lie outside the workspace: --config, MCPLS_CONFIG and the auto-discovered user config (found through $HOME or $XDG_CONFIG_HOME, which a checkout's tooling can set) are all checked. It is checked against the configured roots (or the working directory when none are configured). The working directory is also checked when the path is relative or came from the environment. A working directory that is / or your login home directory (taken from the account database, never from $HOME, which a checkout's tooling can set) is never treated as a checkout. No default config file is created in this mode.
  • Executable. The server's executable is resolved the way a spawn would find it and must exist outside the workspace roots; one that cannot be resolved is refused. mcpls then spawns that resolved absolute path (also on restart and respawn, so a binary added to a workspace directory later is not picked up) with a PATH that has the workspace, relative and empty entries removed. A server always gets such a PATH, even when mcpls itself has none, and a fixed system path if nothing is left, since an empty or missing PATH makes shells and execvp search the current directory. A #!/usr/bin/env node interpreter, and any tool the server itself looks up on PATH, therefore cannot resolve into the workspace through PATH.
  • Environment. Servers start with a cleared environment. What is passed on from mcpls's own is PATH (sanitized as above), HOME, USERPROFILE, TMPDIR, TEMP, TMP and the Windows system variables. 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. In this mode HOME and USERPROFILE are replaced by the login home directory (from the account database) unless the server's env sets them, so a $HOME that names the workspace cannot steer rustup, cargo or npm configuration; mcpls itself also rejects a config file reached through such a $HOME or $XDG_CONFIG_HOME. If the login home cannot be determined (or is not valid UTF-8), a server whose inherited HOME or USERPROFILE lies inside the workspace, or is empty (tools resolve an empty home against the working directory, which is the checkout), is refused, and so is an unset HOME on Unix (~ would resolve against the working directory); only an existing value outside the workspace is passed on unchecked. The USERPROFILE replacement was not verified on Windows. Where $HOME legitimately differs from the account home (CI container jobs with HOME=/github/home, sudo, Nix or Bazel sandboxes), servers see the account home, so toolchains installed under the overridden $HOME are not found: set HOME in that server's env to opt in.
  • tsserver pin. A TypeScript server whose pinned tsserver lies inside the workspace is refused instead of started, and so is one started through a launcher no tsserver can be pinned for: a package runner, a version-manager shim, or a wrapper that only names the server in its arguments (also when you set tsserver.path, since the launcher still picks the server).
  • 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, gawk, mawk, nawk, script, su, flock, watch, and cmd, which without /c reads the LSP frames on its stdin as commands; a shell or interpreter that reads its program from stdin is refused too, see below), wrappers whose options are not analyzed (sudo, sudo-rs, doas, run0, pkexec, runuser, setpriv, gosu, su-exec, chpst, setuidgid, envdir, runas, wsl, strace, unshare, chrt, taskset, ionice, chroot, nsenter, systemd-run: they change the user, root, directory or environment, or take optional arguments) and the run subcommands of bun, deno (including eval and repl), cargo (run, r, test, t, bench), go, uv, pipx, poetry, pdm, hatch, bundle, dotnet, pipenv, pixi, swift, stack (run, exec, runghc, runhaskell, script, eval, ghci, repl) and cabal (run, exec, repl and their v1-, v2- and new- spellings). npx runs ./node_modules/.bin/<name> from the working directory before anything else and reads a workspace .npmrc, so the planted package would run. env is unwrapped; env -S, env -P, a PATH= assignment and a relative program that env -C itself starts cannot be analyzed and are refused (a program a nested wrapper starts is resolved from mcpls' working directory and spawned by its absolute path). A command string cannot be analyzed either, so it is refused for these shells (-c, +c, --command, --commands, PowerShell's -Command, -CommandWithArgs (-cwa) and -EncodedCommand, fish's -C and --init-command, nushell's -e and --execute): sh, bash, zsh, dash, ash, hush, ksh, mksh, lksh, oksh, yash, posh, fish, csh, tcsh, elvish, nu, xonsh, powershell, pwsh (also as a busybox applet). The name may carry a version (ksh93, zsh-5.9) and the suffixes distributions add (-preview, -static, -intel64, -dbg, _d, w, t: pwsh-preview, bash-static, pythonw3.12, python3.13t, python_d). A shell or interpreter flag is read with the whitespace around it removed (runner subcommands and npm: are compared as written). PowerShell reads -, the en dash, the em dash and the horizontal bar alike, and / for a parameter, with any prefix of a parameter name; Windows PowerShell 5.1 (powershell) also reads a bare argument as -Command, and starts the command string at the first parameter it does not recognize, so for it only the known parameters of 5.1 (with the shortest prefix 5.1 accepts, written bare: an attached :value or =value makes the token unknown) and their values are admitted and everything else is refused (what follows -File is the script's own). fish accepts any prefix of a long flag (--comm), elvish one or two dashes (-c, --c, -c=true). The same is refused for these interpreters given an inline program (-e, -E, -c, -p, -r, -R, -B, -F, --eval, --print, php's --run and --process-*, also with the value glued on or after =): node, nodejs, bun, python, py, perl, ruby, php, lua, rscript, julia, osascript; also a perl -M or -m value that is more than a module name and an import list (-MPOSIX;code), and a data: URL (tabs and newlines inside it are ignored, as URL parsing does) given to --import, --loader or --experimental-loader of node, nodejs and bun. pypy is read as python; luajit, jruby and truffleruby are not listed. mcpls writes LSP frames to the server's stdin, so a shell or interpreter that reads its program from it would run them. These shells and interpreters are refused when no script, module or other program source is given (a REPL or bash alone), for -s anywhere in a POSIX shell's option cluster, for a - script operand (bash -, python -, pwsh -File -), for a script under /dev/ or /proc/ (/dev/stdin, /proc/self/fd/0; compared on the text of the operand, case-insensitively, with // and /./ collapsed and \ read as /), for a script path with a .. component, and for a script that resolves to a device, FIFO or socket when looked up from the directory the server starts in (a symlink or an alias such as /Volumes/Macintosh HD/dev/stdin); a missing path or a directory is left to the interpreter. The script is the first operand after the options: POSIX shells read every letter of an option cluster and each value letter (o, O, T, R) takes the next argument, so bash -os pipefail is refused and bash -o pipefail s.sh is not; fish uses getopt rules; zsh -o shinstdin and --shinstdin are refused like -s; nu, elvish, fish, pwsh and every interpreter read an option that is not in their closed list as taking a value. The value is checked like a script (-, /dev/, /proc/, .. are refused), and when no script is left the launch is refused as having an unknown option, so a boolean option that was not listed (node --nolazy srv.js) is named in the refusal. Flags that provide the program themselves are admitted without a script: python -m, nu --lsp and --ide-*, elvish -lsp (unless set to false); php -f FILE and pwsh -File FILE give the script as their value; deno <subcommand> such as deno lsp is admitted and deno alone, bun repl and xonsh --rc (which takes several arguments, and which argparse accepts abbreviated) are refused. A relative script after env -C, ruby -C, bun --cwd or pwsh -WorkingDirectory is refused, since it would resolve in the new directory. --version and --help alone have no script and are refused. The wrappers with a small option grammar (time, nice, nohup, timeout, setsid, stdbuf, caffeinate, arch, and env, also under their Homebrew GNU names gtime, gnice, gnohup, gtimeout, gstdbuf, genv and as busybox, toybox or coreutils applets, the last as coreutils --coreutils-prog=NAME or, for uutils, the bare applet name; --coreutils-prog-shebang= is refused) are parsed against a closed table of their options: the first non-option after the options (and timeout's duration) is the program they start, and what follows it belongs to that program. An option the table does not list, a missing value or command, and nesting deeper than 8 levels are refused and the refusal names the option (never its value). Each program a wrapper starts is then resolved, refused when it lies inside the workspace, and replaced by its absolute path, as the server's own executable is. deno lsp is allowed. These lists are closed, not exhaustive: a shell, interpreter or wrapper that is not named here (numactl, prlimit, ltrace, valgrind, firejail, sg, run-parts, xcrun, sandbox-exec) is admitted without unwrapping it, and its command string is unexamined. Whichever launcher precedes it, though, a server is refused when an argument of its command (or the text after the first = of one, as in --exec=<path>), resolved against the directory the server starts in with symlinks followed, is an executable file inside the workspace; a file without an execute bit (on Windows, without a program extension), a directory and text that is no file are admitted, and so is a program given by bare name, which the sanitized PATH already keeps out of the workspace. An argument that lies inside the workspace and cannot be read (permission denied) is refused as well. An executable data file passed in args is therefore refused. On a file system where every file has the execute bit (WSL drvfs, exFAT, SMB shares) every workspace file in args is refused. A resolved wrapped-program path that contains = is refused when env starts it, because env would read it as an assignment. The list matches the command's file stem and arguments and is best-effort: the trusted configuration is the boundary, so install the server globally and give its absolute path as command.
  • Working directory. The server starts in your login home directory (else the system temporary directory when no other user can write to it, which rules out a shared /tmp), never in the checkout, and is refused when neither is available outside the workspace. Servers get the workspace from workspaceFolders, so a server that treats its working directory as the workspace root, or a relative path in args, no longer resolves into the checkout; give such a server absolute paths and workspace.roots.
  • Windows executable lookups. NoDefaultCurrentDirectoryInExePath=1 is always set in the server's environment (a value you set is overridden), and the working directory above is outside the workspace, so cmd.exe running an npm .cmd shim, and a Node server starting python or git by name, do not find a node.exe, node.cmd or node.bat in the checkout. Verified on Windows only by the CI test that starts a .cmd server and records its working directory.

What the mode does not cover:

  • Interpreter arguments that are not executable files: node <workspace>/cli.mjs runs workspace code, and only the node executable is checked, unless cli.mjs has an execute bit (any executable file named in args is refused, see Launcher).
  • With no configured workspace.roots, the working directory is the checkout unless it is / or the login home. With no account entry, or a $HOME that differs from it and equals the working directory, binaries under it (such as ~/.cargo/bin) and a config found there are refused: configure workspace.roots.
  • Directories above a configured root, such as a monorepo around the configured package, count as outside the workspace.
  • Case-insensitive file systems and hardlinks are not specifically handled or tested: a hardlinked binary has no distinguishable location.
  • On Windows the standard library also searches the application directory and the system directories; mcpls resolves only the child PATH, so a command it cannot resolve is refused rather than guessed.
  • The mode is not surfaced to MCP clients through get_info; the refusals are in the error text of the affected tool calls and in the log.
  • TMPDIR, TEMP and TMP are passed on as inherited, and values a server's own env sets are used as written. When the login home is unknown, a HOME set to the parent of a configured root (roots [<ws>/pkg], HOME=<ws>) is not refused: it falls under the directories above a configured root listed below.
  • Launchers that choose the real server from files in the workspace and are not on the launcher list: rustup honors a workspace rust-toolchain.toml whose path names a toolchain inside it, so the rust-analyzer proxy outside the workspace can run a binary from the workspace; asdf, mise and Volta shims (refused only for the TypeScript server) pick versions from workspace files; Go switches toolchains from go.mod. The executable check sees only the launcher, and the launcher list is best-effort.
  • The argument check (see Launcher) runs when the server is planned and again right before every spawn, including startup, restarts and respawns. A restart refused by the pre-restart check leaves the running server untouched; the same check also re-plans a trusted TypeScript server's tsserver pin, so a pin that no longer resolves refuses the restart instead of stopping the server. A file that becomes executable after the last check but before the launched program executes it is not caught. A restart whose spawn is refused after the old process was stopped (the file changed in between) leaves the server stopped, not running unchecked.
  • Programs that read their program from stdin are refused only for the shells and interpreters on the lists above. Not covered: shells, interpreters and REPLs that are not on them; options that start a REPL or read stdin next to a script (python -i, node -i) and environment keys that do the same (PYTHONINSPECT); a script that is replaced by a device alias between the check and the spawn; an alias such as /dev/fd/0, which is resolved in the mcpls process, not the child's (best-effort); and Windows console devices (CONIN$), which the file-type check does not see.
  • A server restarted or respawned runs the path resolved at startup. If that path goes through a symlink inside the workspace, the symlink can be repointed later; only the resolved path's own directory is checked, not every link of a chain.
  • TypeScript pin: a pin that resolves inside the workspace is refused, as is a launcher that cannot be pinned. A server that is pinnable but has no valid typescript next to it is admitted without a pin. mcpls starts the server without a rootUri, so it does not look up a workspace tsserver on its own, which is why this is not treated as a bypass.
  • Code the allowed servers run on their own (build scripts, procedural macros, tsconfig plugins) is outside every check above.

A project-scoped MCP client config (for example a .mcp.json in the analyzed repository) controls the arguments mcpls is launched with, so it can drop --workspace-trust untrusted or add --allow-server. Set the mode in your user-scoped client configuration.

Run mcpls against untrusted code only inside an environment you are willing to have that code execute in (a container or a disposable VM).

There aren't any published security advisories