Only the latest released minor version of mcpls receives security fixes.
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.
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
mcplsconfig. A project-local./mcpls.tomlcan name server commands and environment, and set per-serversettingsandinitialization_options, which can steer what a server executes (for example pyright's interpreter or rust-analyzer'scheck.overrideCommand). It is ignored unless you pass--trust-project-config. This flag governs only themcplsconfig. 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
mcplsat an untrusted checkout (a cloned third-party repository, a pull-request branch) can run that checkout's code through the serversmcplsauto-spawns:Server Workspace-supplied code the server can run typescript-language-server node_modules/typescript/lib/tsserver.jsfrom 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 networkrust-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.modclangd Commands from compile_commands.jsonand.clangdconfigurationOnly the typescript-language-server row has been verified live. The other rows describe the well-known behavior of those servers.
-
tsserver pin. By default
mcplspasses the tsserver bundled next to typescript-language-server asinitializationOptions.tsserver.path, so the workspace's own tsserver is not selected. The pin applies to these installs, each with atypescriptpackage, with apackage.jsoncarrying aversion, next to the server:npm -gstyle symlink installs (verified with Homebrew's node);- npm
.cmd,.ps1and extensionless shims beside anode_modulesthat 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; nodeorbunrunning the server's absolutecli.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.cmdneeds that name ascommand. 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, includingnodeorbunwith a relative script path; - no valid
typescriptpackage is found next to the server; - you set
initialization_optionsfor the server withouttsserver.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.pathto 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
typescriptand the server itself is workspace code, so nothing is narrowed. Atsserver.pathyou 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
tscselection. An entry withselection = "auto"(the generated TypeScript default) may starttsc --lsp --stdiofrom a TypeScript 7 install. mcpls accepts only atypescript/bin/tscthat canonicalizes to a path outside every workspace root, so a workspacenode_modulesis never selected. On Windows the npmtsc.cmdshim is mapped to its package and the server starts asnode <package>\bin\tsc --lsp --stdio, never throughcmd.exe. Thatnodeis the firstnode.exeon the server's effectivePATHand passes the same check: if it or thePATHdirectory 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. Removeselectionto runcommandas written. -
Explicitly configured workspace binaries. A server
commandyou configure, for example a TypeScript 7tscfor the native server (tsc --lsp --stdio), is run as written. Atscfrom the workspace'snode_modules, or one thatPATHresolves into the workspace, is workspace-supplied code. The tsserver pin does not apply to it.
--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_CONFIGand the auto-discovered user config (found through$HOMEor$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
PATHthat has the workspace, relative and empty entries removed. A server always gets such aPATH, even when mcpls itself has none, and a fixed system path if nothing is left, since an empty or missingPATHmakes shells andexecvpsearch the current directory. A#!/usr/bin/env nodeinterpreter, and any tool the server itself looks up onPATH, therefore cannot resolve into the workspace throughPATH. - Environment. Servers start with a cleared environment. What is passed
on from mcpls's own is
PATH(sanitized as above),HOME,USERPROFILE,TMPDIR,TEMP,TMPand the Windows system variables.NODE_OPTIONS,LD_PRELOAD,DYLD_INSERT_LIBRARIES,PYTHONPATH,RUSTC_WRAPPERandXDG_CONFIG_HOMEare not passed unless the server's ownenvsets them. In this modeHOMEandUSERPROFILEare replaced by the login home directory (from the account database) unless the server'senvsets them, so a$HOMEthat names the workspace cannot steer rustup, cargo or npm configuration; mcpls itself also rejects a config file reached through such a$HOMEor$XDG_CONFIG_HOME. If the login home cannot be determined (or is not valid UTF-8), a server whose inheritedHOMEorUSERPROFILElies 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 unsetHOMEon Unix (~would resolve against the working directory); only an existing value outside the workspace is passed on unchecked. TheUSERPROFILEreplacement was not verified on Windows. Where$HOMElegitimately differs from the account home (CI container jobs withHOME=/github/home,sudo, Nix or Bazel sandboxes), servers see the account home, so toolchains installed under the overridden$HOMEare not found: setHOMEin that server'senvto 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
commandthat 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), 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 ofbun,deno(includingevalandrepl),cargo,go,uv,pipx,poetry,pdm,hatch,bundle,dotnet,pipenv,pixi,swift,stackandcabal.npxruns./node_modules/.bin/<name>from the working directory before anything else and reads a workspace.npmrc, so the planted package would run.envis unwrapped;env -S,env -P, aPATH=assignment and a relative program thatenv -Citself 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,--command,--commands,/c,/k,/r, PowerShell's-Command,-CommandWithArgs(-cwa) and-EncodedCommand, fish's-Cand--init-command, nushell's-eand--execute):sh,bash,zsh,dash,ash,hush,ksh,mksh,oksh,yash,posh,fish,csh,tcsh,elvish,nu,xonsh,cmd,powershell,pwsh(also as abusyboxapplet, and with a version suffix such asksh93), and for these interpreters given an inline program (-e,-E,-c,-p,-r,--eval,--print, also with the value glued on or after=):node,nodejs,bun,python,perl,ruby,php,lua,rscript,julia,osascript; also a perl-Mor-mvalue that is more than a module name and an import list (-MPOSIX;code), and adata:URL given to--import,--loaderor--experimental-loaderofnode,nodejsandbun. The wrappers with a small option grammar (time,nice,nohup,timeout,setsid,stdbuf,caffeinate,arch, andenv, also under their Homebrew GNU namesgtime,gnice,gnohup,gtimeout,gstdbuf,genvand asbusybox,toyboxorcoreutilsapplets, the last ascoreutils --coreutils-prog=NAMEor, 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 (andtimeout'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 lspis 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 itscommand(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 sanitizedPATHalready 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 inargsis therefore refused. On a file system where every file has the execute bit (WSL drvfs, exFAT, SMB shares) every workspace file inargsis refused. A resolved wrapped-program path that contains=is refused, becauseenvwould 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 ascommand. - 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 fromworkspaceFolders, so a server that treats its working directory as the workspace root, or a relative path inargs, no longer resolves into the checkout; give such a server absolute paths andworkspace.roots. - Windows executable lookups.
NoDefaultCurrentDirectoryInExePath=1is always set in the server's environment (a value you set is overridden), and the working directory above is outside the workspace, socmd.exerunning an npm.cmdshim, and a Node server startingpythonorgitby name, do not find anode.exe,node.cmdornode.batin the checkout. Verified on Windows only by the CI test that starts a.cmdserver and records its working directory.
What the mode does not cover:
- Interpreter arguments that are not executable files:
node <workspace>/cli.mjsruns workspace code, and only thenodeexecutable is checked, unlesscli.mjshas an execute bit (any executable file named inargsis 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$HOMEthat differs from it and equals the working directory, binaries under it (such as~/.cargo/bin) and a config found there are refused: configureworkspace.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,TEMPandTMPare passed on as inherited, and values a server's ownenvsets are used as written. When the login home is unknown, aHOMEset 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.tomlwhosepathnames a toolchain inside it, so therust-analyzerproxy 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 fromgo.mod. The executable check sees only the launcher, and the launcher list is best-effort. - The argument check (see Launcher) runs once, when the server is planned. A
restart or respawn reuses the admitted configuration, so a file in
argsthat becomes executable afterwards is not caught. - 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
typescriptnext to it is admitted without a pin. mcpls starts the server without arootUri, 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).