SkillTotal

Is fastmcp safe?

Some risk - review before installing
Notable — review in context (capabilities are not malware):
  • Python shell/command execution
  • Unsafe deserialization
  • Python filesystem read

fastmcp is an AI python_package analyzed by SkillTotal's deterministic static scanner. The scan found no malicious indicators, though 9 risky constructs are reported for review. It can: delegated authentication, filesystem read, filesystem write, mcp tools detected, network egress and shell execution — capabilities are what the code can do, not a verdict on intent. Risk score 40/100 (medium).

fastmcp 4.0.10

python_package · pypi:fastmcp
MEDIUM
40
/ 100 risk score
Snapshot · scanned Sep 25, 2026 · fastmcp@4.0.10 · engine 0.53.0 / ruleset 60

Automated static-analysis result. It can contain false positives and false negatives, and is not a claim about the intent of fastmcp's authors. Report a false positive.

Capabilities — what this component can do (not a risk score):
delegated authenticationfilesystem readfilesystem writemcp tools detectednetwork egressshell execution

Behavioral traits

How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.

Tool surface
Tool Usage
Execution authority
Tool Access Control / Direct Tool Access
Filesystem reach
Tool Execution Context
Network egress
Interaction & Communication / Direct Communication
Network exposure
Interaction & Communication
Delegated authentication
Tool Execution Context / User Delegated Credentials
Supply-chain provenance risk
General Protections / Supply Chain
Unsafe deserialization
General Protections / Input Validation

Findings (9)

HIGHUnsafe deserializationST-DESERIALIZE-PY

It loads data with a format that can rebuild arbitrary objects (e.g. pickle, or unsafe YAML).

parsed = yaml.load(frontmatter_text, Loader=yaml.BaseLoader)

Why it matters: Feeding such a loader untrusted data can execute code hidden inside that data.

Fix: Deserialize untrusted data with a safe format/loader: JSON, or yaml.safe_load / Loader=SafeLoader. Reserve pickle/marshal for data you fully control.

HIGHPython shell/command executionST-SHELL-PY

The component can run operating-system commands or spawn processes.

out = subprocess.check_output(
        [
            "gh",
            "api",
            "-X",
            "POST",
            f"repos/{REPO}/releases/generate-notes",
            "-f",
            f"tag_name={tag}",
            "-f", …
process = await asyncio.create_subprocess_exec(
        *cmd,
        env=env,
        start_new_session=sys.platform != "win32",
    )
subprocess.run([cmd, "--version"], check=True, capture_output=True)
process = subprocess.run(
            [npx_cmd, inspector_cmd, *uv_cmd],
            check=True,
            env=env,
        )
process = subprocess.run(cmd, check=True, env=env)
process = subprocess.run(cmd, check=True, env=env)
subprocess.run(
            [
                "powershell.exe",
                "-NoLogo",
                "-NoProfile",
                "-NonInteractive",
                "-Command",
                _WINDOWS_ACL_SCRIPT,
            ], …
result = subprocess.run(
                [claude_in_path, "--version"],
                check=True,
                capture_output=True,
                text=True,
            )
result = subprocess.run(
                    [str(path), "--version"],
                    check=True,
                    capture_output=True,
                    text=True,
                )
subprocess.run(cmd_parts, check=True, capture_output=True, text=True)
subprocess.run(
                [gemini_in_path, "--version"],
                check=True,
                capture_output=True,
            )
subprocess.run(
                    [str(path), "--version"],
                    check=True,
                    capture_output=True,
                )
subprocess.run(cmd_parts, check=True, capture_output=True, text=True)
subprocess.run(["open", url], check=True, capture_output=True)
subprocess.run(["xdg-open", url], check=True, capture_output=True)
process = subprocess.run(cmd, check=True)
process = await asyncio.create_subprocess_exec(
                *cmd,
                stdin=None,
                stdout=None,
                stderr=None,
                # Own process group so _terminate_process can kill the whole tree …
subprocess.run(
                [
                    "uv",
                    "init",
                    "--project",
                    str(output_dir),
                    "--name",
                    "fastmcp-env",
                ] …
subprocess.run(
                    [
                        "uv",
                        "python",
                        "pin",
                        self.python,
                        "--project",
                        str(outpu …
subprocess.run(
                [
                    "uv",
                    "add",
                    *dependencies,
                    "--no-sync",
                    "--project",
                    str(output_dir), …
subprocess.run(
                    [
                        "uv",
                        "add",
                        "-r",
                        str(req_path),
                        "--no-sync",
                        "--project" …
subprocess.run(
                    [
                        "uv",
                        "add",
                        "--editable",
                        *editable_paths,
                        "--no-sync",
                        " …
subprocess.run(
                ["uv", "sync", "--project", str(output_dir)],
                check=True,
                capture_output=True,
                text=True,
            )
result = subprocess.run(
        [sys.executable, "-c", _PROBE],
        capture_output=True,
        text=True,
        check=False,
        timeout=30,
    )
r = subprocess.run(
        [sys.executable, "-c", code],
        capture_output=True,
        text=True,
        timeout=30,
    )

Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.

Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; avoid shell=True.

MEDIUMAgent or editor configuration runs a command automaticallyST-AGENT-AUTORUN

The component ships agent or IDE configuration that executes a command without a separate step: a Claude Code / Gemini CLI / Cursor hook, or a VS Code task that runs when the folder opens. Opening the project in that tool runs it with the developer's privileges.

"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh",

Fix: Read the command and anything it runs before opening this project in an agent or editor. A published package has no reason to ship project auto-run config.

MEDIUMServer bound to all network interfacesST-EXPOSE-BIND

A server is bound to all network interfaces (0.0.0.0), not just your own machine.

Why it matters: Without authentication, other hosts on the network can reach it.

Fix: Bind to 127.0.0.1 for local-only use, or require authentication and restrict access if remote exposure is intended.

MEDIUMPython filesystem readST-FS-PY-READ

The component reads files from disk.

line.strip() for line in open(notes_path).read().split("\n") if line.strip()
app_bridge_js = app_bridge_cache.read_text(encoding="utf-8")
with open(Path(server_spec)) as f:
data = json.loads(path.read_text())
return model.model_validate_json(path.read_text(encoding="utf-8"))
text = path.read_text(encoding="utf-8")
text = path.read_text(encoding="utf-8")
text = path.read_text(encoding="utf-8")
content := file_path.read_text(encoding="utf-8").strip()
content: str | bytes = await self._async_path.read_bytes()
content = await self._async_path.read_text(encoding=self.encoding)
return main_file_path.read_text(encoding="utf-8")

Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.

Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.

MEDIUMPython filesystem write/deleteST-FS-PY-WRITE

The component writes or deletes files on disk.

path.write_text(text[:idx] + block + "\n" + text[idx:])
app_bridge_cache.write_text(app_bridge_js, encoding="utf-8")
with open(output_path, "w") as f:
skill_path.write_text(skill_content)
config_file.write_text('{"mcpServers": {}}')
file_path.write_text(self.model_dump_json(indent=2), encoding="utf-8")
file_path.write_text(content.text, encoding="utf-8")
file_path.write_bytes(base64.b64decode(content.blob))
cache_path.write_text(
            json.dumps({"latest_version": latest_version, "timestamp": time.time()})
        )

Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.

Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.

MEDIUMPython network egressST-NET-PY

The component makes outbound network requests.

Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.

Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.

LOWDelegated authentication (OAuth 2.0 / OIDC)ST-AUTH-DELEGATED

An OAuth 2.0 / OpenID Connect delegated-authentication flow was detected (authorization-code / refresh-token / token-exchange grant, an OIDC authorize/discovery endpoint or id_token, or a delegation library). Tools authenticate with the end user's delegated, scoped credentials rather than a long-lived embedded service credential. (25 occurrence(s) shown as evidence).

# These providers pull in heavy deps (authlib, cryptography, key_value.aio,
upstream_authorization_endpoint="https://login.example.com/oauth/authorize",
f"/.well-known/openid-configuration{issuer_path}",
# Always register /.well-known/openid-configuration regardless of path depth.
#    /{prefix}/.well-known/openid-configuration arrives here as
#    /.well-known/openid-configuration.
"/.well-known/openid-configuration",
"(`{issuer}/.well-known/openid-configuration`) unless overridden in "
Fetches ``{issuer}/.well-known/openid-configuration`` and returns its
config_url = issuer.rstrip("/") + "/.well-known/openid-configuration"
from authlib.common.security import generate_token
access_token, and for OIDC providers may include id_token,
to verify a different token (e.g., the OIDC id_token for providers
# When alternate verification is in use (e.g., id_token
# JWTs for both access_token and id_token, which would cause
Replaces `authlib.integrations.httpx_client.AsyncOAuth2Client` for the OAuth
proxy's upstream token-endpoint calls. authlib's httpx integration imports the
legacy `httpx` package — which authlib does not declare as a dependency and
- form-encoded POST token requests with authlib's default headers
- `client_secret_basic` (latin-1 basic auth, authlib-style), `client_secret_post`,
- `OAuthError` (authlib's httpx-free core error class) raised for RFC 6749
from authlib.integrations.base_client import OAuthError
Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that
# Mirror authlib's OAuth2Token: derive expires_at from expires_in so

Fix: Delegated auth is a lower-blast-radius execution context than an embedded static credential. Confirm the requested scopes are minimal and that tokens are never logged or forwarded off-host.

LOWMCP tool surface detectedST-MCP-DETECTED

An MCP tool surface (manifest or tool definitions) was found.

Why it matters: Just context — review which tools it offers and their permissions.

Fix: Review the declared MCP tools and their permissions.

Check your own component

Run the same evidence-backed scan on any MCP server, agent skill, or package.

Scan your own component

How we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →