PyFu

FastMCP Tools

Python Web Development Frameworks

Tools are the functions an MCP client can call. In FastMCP a tool is any function decorated with @mcp.tool; its type hints become the JSON schema the client sees, and its return value is sent back as the result.

from fastmcp import FastMCP

mcp = FastMCP("internal-tools")

@mcp.tool
def lookup_user(user_id: int) -> dict:
    """Return a user record by id."""
    return db.get(user_id)

A client discovers tools by sending tools/list and invokes one with tools/call, naming the tool and passing arguments that match the schema. FastMCP validates the arguments against the type hints (the same Pydantic-driven validation FastAPI uses) and then calls the function. The docstring and parameter names are shipped to the client verbatim, because they are what the model reads to decide when and how to call the tool.

Why tools matter from an offensive security perspective

A tool is server-side Python that runs with the server’s privileges, exposed as a callable endpoint. That is the entire risk in one sentence. Whatever a tool does, it does for anyone who can reach the transport, and on a no-auth network server that is anyone who can reach the port.

The danger is therefore the body of each tool. A tool that wraps a shell, the filesystem, or an outbound request is the corresponding vulnerability class, now invokable with no authentication:

@mcp.tool
def run_command(cmd: str) -> str:
    # An unauthenticated RCE primitive the instant the transport is on the network.
    return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout

That tool reproduces Python Command Injection with the network as the entry point. A tool that opens a path the caller supplies is Insecure File Access and Path Traversal in Python; one that fetches a URL is Server Side Request Forgery (SSRF) in Flask Applications. The tool arguments are fully attacker-controlled: in an agent setting the model chooses them, and the model’s instructions can be steered by injected content (see Prompt Injection in Python LLM Backends), but on an exposed server an attacker skips the model entirely and calls tools/call directly with whatever arguments they want.

FastMCP enforces argument types, not authorization. There is no built-in notion of which caller may invoke which tool, so a tool is reachable the moment the server is. When you assess a server, enumerate tools/list and read every implementation, treating each tool as the sink it wraps. The end-to-end exploitation, from enumeration to a tool-driven shell, is in Unauthenticated FastMCP Servers (lab: mcp-fu/fastmcp-no-auth), and the wiring that should gate these calls is in FastMCP Authentication.

Mitigation

Write each tool as if its arguments are hostile, because they are, and gate the server itself behind authentication so the tool is not reachable by anyone who finds the port. Never pass a tool argument into a shell, a raw filesystem path, a URL, or a query string without validating and constraining it first: use shell=False with an argument list instead of shell=True, resolve and contain file paths under a fixed base directory, allow-list outbound hosts, and parameterize queries. Keep tools narrowly scoped to the one operation they need rather than exposing a general-purpose primitive, and require an auth provider (see FastMCP Authentication) on any network transport so type validation is never the only thing standing between a caller and the sink.

import shlex, subprocess

ALLOWED = {"uptime", "df"}

@mcp.tool
def run_diagnostic(name: str) -> str:
    # Allow-list, no shell, fixed argv. No attacker-chosen command string.
    if name not in ALLOWED:
        raise ValueError("unknown diagnostic")
    return subprocess.run([name], shell=False, capture_output=True, text=True).stdout