Skip to content
verify mcp Beta VerifyMCP is currently in beta. If you notice any issues, get in touch and we’ll put it right.

Avoid install scripts in your MCP package

What we checked

We looked at your package metadata for install-time scripts: code the package manager runs automatically during installation, before you’ve run a single intentional line. The signal passes when no such scripts are declared. If your package declares one, it’s flagged as an un-assessed risk factor: a hook we could not review. If our scanning provider has since assessed that hook, we record the risk as assessed instead. And if we couldn’t tell either way whether scripts are declared, the signal is unverifiable and scores 0 until we can assess it.

Why it matters

An install script runs with your shell’s permissions the moment a user (or an agent) types install, with no review, no opt-in, often no awareness it happened. That makes it one of the most heavily abused vectors in supply-chain attacks:

  • a hijacked or typosquatted package can exfiltrate environment variables, tokens, or SSH keys from a postinstall hook;
  • the malicious code never appears in the source a reviewer reads at runtime, so it hides in plain sight;
  • because it fires on install, CI machines and developer laptops are exposed before any test or sandbox boundary applies.

A server that needs no install script is strictly safer to add, and consumers increasingly install with scripts disabled by default.

How to fix it

This is mostly an npm/Node concern (Python has its own shape, covered below). Move any work a script was doing into an explicit runtime step or a documented CLI command the user runs deliberately, and delete the install/postinstall/preinstall hooks from package.json:

{
"name": "your-mcp-server",
"scripts": {
  "postinstall": "node ./scripts/setup.js",
  "start": "node ./dist/index.js"
}
}

If a script is genuinely unavoidable (for example, compiling a native addon), keep it minimal, do only what’s strictly necessary, and document in your README exactly what it does and why.

Python packages

Python has no postinstall hook, but a release can still run its own code without anyone asking it to, in five ways, and we check each:

  • At build time, from an sdist. When pip has to build your source distribution, it runs the build backend declared in pyproject.toml, and a setup.py is ordinary Python that runs as part of that build. We give full marks to a recognised backend (setuptools, Poetry, Hatch, Flit, PDM, maturin or uv). Any setup.py, a backend-path that loads the backend from inside your own sdist, scikit-build-core or meson-python (each runs your own CMake or meson.build scripts, which we do not read), an unrecognised backend, or a pyproject.toml we cannot parse as TOML is graded as a hook we could not review.
  • At build time, from a build hook. Even a recognised backend can run your own code while it builds: a top-level hatch_build.py or pdm_build.py, a Hatch build or metadata hook in pyproject.toml or hatch.toml (except hatch-vcs, hatch-fancy-pypi-readme and hatch-requirements-txt), a Hatch or PDM version source that runs code, a Poetry build script, a PDM custom-hook, a [tool.setuptools.cmdclass] table, or a cmdclass in setup.cfg. Each is graded as a hook we could not review.
  • At build time, from a dynamic version. A setuptools attr: version or a Flit dynamic version or description can make the backend import your package. We read your source to check, and it passes when all of these hold: the value is a plain string (__version__ = "1.2.3", not computed or imported); your code uses a flat or src layout, with the module in one place only and no compiled files beside it; and build-system.requires lets pip install a current setuptools or flit_core. If you pin your backend, prefer a floor (setuptools>=68) over exclusions (!=69.0), and list only setuptools, wheel, setuptools-scm or flit-core there. If we cannot prove the value is a plain string, it is graded as a hook we could not review.
  • At build time, from the sdist’s own contents. A .git, .hg, .svn or .bzr folder shipped in your sdist is run through the VCS tool by versioning plugins, so don’t ship one. Without a declared build-backend, setuptools’ legacy mode can import a stray module sitting at the root of your sdist in place of its own, so declare build-backend = "setuptools.build_meta" and keep the root to your own package. Either is graded as a hook we could not review.
  • At every Python start-up, from a wheel. Installing a wheel runs nothing, but it can leave code that runs every time Python starts: a .pth file with an import line, or a top-level sitecustomize or usercustomize module. Either is graded the same way.

We read the release’s sdist and its wheels (the first 8, within 200 MiB of downloads), matching file names the way pip does, so SETUP.PY or ./setup.py counts as setup.py. A wheel-only release with none of these passes. If we could not download or read one of your files, it is too big to walk in full (more than 5,000 entries in an sdist or 20,000 in a wheel), or it is built in an unusual way that pip and our reader could see differently, and we found nothing, the result is unverifiable rather than a pass; if we did find a hook, it is graded as usual. Archives built with python -m build, uv, Hatch, Poetry, Flit or PDM read normally. If PyPI itself did not answer, the result is unverifiable too, never charged to you. See Python packages for how to set up a release that passes.

How we re-check

We re-read your package metadata on our next crawl. Once the published version no longer declares install-time scripts, the signal flips to a pass on the following score refresh.

Give this to your AI

Paste this into Claude Code (or any coding agent) from inside your server's repository. It states the failing signal, the outcome we re-check for, and the format the fix has to take.

Prompt
Context: this repository publishes an MCP server. VerifyMCP's install
scripts check failed, which means the published package declares an
install-time script (`install`, `preinstall`, or `postinstall`) that runs
automatically, with your shell's permissions, before anyone has run a
single intentional command.

Goal: the published package declares no install-time script, so the next
crawl records it as clean.

Do this:
1. Confirm which ecosystem the package is published to. The steps below
   are for npm. For a Python package, follow the "Python packages" callout
   on this page instead: build with a recognised PEP 517 backend, no
   `setup.py` and no build hook (`hatch_build.py`, `pdm_build.py`, a custom
   Hatch, PDM or Poetry hook, or a `cmdclass` table), declare a
   `build-backend` in `pyproject.toml`, keep a dynamic version a plain string
   (`__version__ = "1.2.3"`) in a flat or `src` layout, keep `.git` and other
   VCS folders out of the sdist, and remove any `.pth` import line or
   `sitecustomize` / `usercustomize` module from the wheel.
2. Read `package.json` `scripts` for `install`, `preinstall`, and
   `postinstall` entries and work out what each one actually does.
3. Move any setup work into an explicit runtime step, or a documented CLI
   command the user runs deliberately (for example a
   `your-mcp-server init` command), instead of running it at install
   time.
4. Delete the `install`/`preinstall`/`postinstall` hooks from
   `package.json` once nothing depends on them running automatically.
5. Verify the package still installs and works with
   `npm install --ignore-scripts`; if it breaks, more logic still needs
   moving to runtime.
6. If a script is genuinely unavoidable (for example compiling a native
   addon), ask me before keeping it. If we keep it, make it as minimal as
   possible and document in the README exactly what it does and why.
7. Prepare the release, then stop and let me run the publish myself, since
   the check reads the published metadata.

Rules:
- Never run the publish command (`npm publish`, `twine upload`, `dotnet nuget
  push`, `cargo publish`, a release tag that triggers one) yourself. A
  published version is permanent and notifies every downstream consumer:
  prepare the change, show me the diff, and let me publish it.
- Never rename or obfuscate a hook to dodge detection; the fix is
  removing the behaviour, not hiding it.
- Never silently keep a script that does something other than what the
  README says it does.
- Ask me before deciding a script is "unavoidable" rather than assuming
  it.

Report back: which hooks you removed or kept, where the logic moved to,
and confirmation that `npm install --ignore-scripts` still works.

Reference: https://verifymcp.io/docs/packages/install-scripts

Written by Stuart Blackler · Last reviewed 25 September 2026.