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; node,nodejsorbun(also versioned, such asnode22) running 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, includingnode,nodejsorbun(also versioned, such asnode22) with 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. -
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/proca 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 asgit checkoutdropping 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 withopenat2confined beneath the root (no symlink or magic link is followed, so an outside target is never opened), falling back to a per-componentO_NOFOLLOWwalk whereopenat2is 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/didSaveto 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 advertisestextDocumentSync.save(the client capabilities declaresynchronization.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. DynamicdidSaveregistration is not tracked. In untrusted-workspace mode the sweep never sendsdidSave. AdidSavefollows a content digest, so only a file whose text changed sends one. -
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, andcmd, which without/creads 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 ofbun,deno(includingevalandrepl),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) andcabal(run,exec,repland theirv1-,v2-andnew-spellings).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,+c,--command,--commands, PowerShell's-Command,-CommandWithArgs(-cwa) and-EncodedCommand, fish's-Cand--init-command, nushell's-eand--execute):sh,bash,zsh,dash,ash,hush,ksh,mksh,lksh,oksh,yash,posh,fish,csh,tcsh,elvish,nu,xonsh,powershell,pwsh(also as abusyboxapplet). 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 andnpm: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:valueor=valuemakes the token unknown) and their values are admitted and everything else is refused (what follows-Fileis 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--runand--process-*, also with the value glued on or after=):node,nodejs,bun,python,py,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 (tabs and newlines inside it are ignored, as URL parsing does) given to--import,--loaderor--experimental-loaderofnode,nodejsandbun.pypyis read as python;luajit,jrubyandtrufflerubyare 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 orbashalone), for-sanywhere 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, sobash -os pipefailis refused andbash -o pipefail s.shis not; fish uses getopt rules;zsh -o shinstdinand--shinstdinare refused like-s;nu,elvish,fish,pwshand 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 --lspand--ide-*,elvish -lsp(unless set to false);php -f FILEandpwsh -File FILEgive the script as their value;deno <subcommand>such asdeno lspis admitted anddenoalone,bun replandxonsh --rc(which takes several arguments, and which argparse accepts abbreviated) are refused. A relative script afterenv -C,ruby -C,bun --cwdorpwsh -WorkingDirectoryis refused, since it would resolve in the new directory.--versionand--helpalone have no script and are refused. 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 whenenvstarts it, 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 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
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).