Skip to content
T threat.cafe

· Nadia Ferreira-Strand (editorial persona) · Agent artifact supply chain · on threat.cafe

You can't govern what you can't list: an inventory of skills, MCP servers and hooks across developer laptops

To find out which skills, MCP servers and hooks your organisation runs, read the agent clients' own configuration files on every developer machine and in every code checkout. Do not wait for EDR or a SIEM to report them. They live in per-user JSON files and folders, such as ~/.claude.json, .mcp.json, ~/.cursor/mcp.json and .claude/skills/, that endpoint tools do not parse. Collect them with a scheduled script through your device-management tool, store names, commands, URLs and hashes, and never collect secret values. Then you can answer "are we exposed?" in minutes when an incident names a skill or server.

Outcome, time and access

Outcome. A CSV per machine, merged into one table: host, user, client, scope, kind, file, name and detail.

Time. Two days to pilot on ten machines. One week to roll out through MDM.

Access you need.

  • Permission to run a script as each user through your MDM or endpoint management tool.
  • A list of code root folders per platform, such as ~/src or ~/code.
  • A place to store results that only the security team can read.

Why the usual tools miss this

The gap is documented by vendors on both sides. A Cloud Security Alliance post of 25 June 2026 describes skill installs bluntly: "No centralized registry. No MDM integration. No SIEM event when someone pulls a SKILL.md from GitHub." Palo Alto Networks' February 2026 post says "nonbinary software", including "local servers (including MCP)", often falls "outside the visibility and control of traditional endpoint security tooling."

A skill is a folder with a markdown file. An MCP server is a JSON entry that names a command or a URL. Neither is a new executable that EDR flags at install time.

Use this runbook if / do not use it if

Use it if developers run Claude Code, Cursor, VS Code with Copilot agent mode, or Copilot CLI, and you cannot list their MCP servers today.

Do not use it as your only source for remote connectors that a client fetches from a cloud account. Those may not exist on disk. Pull them from each vendor's admin console.

The config-path map (read 15 October 2026)

Paths change between client versions. Re-check each vendor page quarterly.

Client        Kind     Scope       Path
Claude Code   MCP      user/local  ~/.claude.json  (local scope sits under that project's path)
Claude Code   MCP      project     <repo>/.mcp.json
Claude Code   MCP      managed     managed-mcp.json in the system directory (below)
Claude Code   hooks    user        ~/.claude/settings.json
Claude Code   hooks    project     <repo>/.claude/settings.json, <repo>/.claude/settings.local.json
Claude Code   hooks    managed     managed-settings.json in the system directory (below)
Claude Code   skills   user        ~/.claude/skills/<name>/SKILL.md
Claude Code   skills   project     <repo>/.claude/skills/<name>/SKILL.md
Claude Code   plugins  user        ~/.claude/plugins (installed_plugins.json records installs)
Cursor        MCP      user        ~/.cursor/mcp.json
Cursor        MCP      project     <repo>/.cursor/mcp.json
Cursor        hooks    user        ~/.cursor/hooks.json
Cursor        hooks    project     <repo>/.cursor/hooks.json
Cursor        hooks    enterprise  /Library/Application Support/Cursor/hooks.json (macOS),
                                   /etc/cursor/hooks.json (Linux), C:\ProgramData\Cursor\hooks.json
VS Code       MCP      workspace   <repo>/.vscode/mcp.json  (top-level "servers" object)
VS Code       MCP      user        profile mcp.json, opened with "MCP: Open User Configuration"
Copilot CLI   MCP      user        ~/.copilot/mcp-config.json  (moves with COPILOT_HOME)
Copilot CLI   agents   user/repo   ~/.copilot/agents, <repo>/.github/agents

Claude Code system directory: /Library/Application Support/ClaudeCode/ (macOS),
/etc/claude-code/ (Linux, WSL), C:\Program Files\ClaudeCode\ (Windows)

Sources: Anthropic's Claude Code pages on MCP, the .claude directory and managed settings; Cursor's MCP and hooks pages; Microsoft's VS Code MCP page; GitHub's Copilot CLI page. Claude Code moves ~/.claude when CLAUDE_CONFIG_DIR is set, and Copilot CLI moves ~/.copilot when COPILOT_HOME is set.

Pre-flight checks

  1. Pick five pilot machines across macOS, Linux and Windows.
  2. On each, confirm the files exist where the map says. Run claude mcp list on one machine with Claude Code. Verify: every server it lists appears in a file on the map, or comes from a plugin or a cloud connector.
  3. Check for overrides. Run env | grep -E 'CLAUDE_CONFIG_DIR|COPILOT_HOME'. Do not proceed until the script reads the overridden paths on those machines.

Step-by-step

  1. Deploy the collector. The script below reads the map's user-level files, then walks the code roots in CODE_ROOTS for project files. It records server names with their command or URL, hook commands, and a SHA-256 of each SKILL.md. It skips env and headers blocks.
#!/usr/bin/env python3
"""Inventory agent MCP servers, hooks and skills for the current user.

Prints CSV to stdout. Records names, commands and URLs only; never values
from env or headers blocks, which often hold secrets.
"""
import csv
import hashlib
import json
import os
import socket
import sys
from pathlib import Path

HOME = Path.home()
CODE_ROOTS = [Path(p) for p in os.environ.get("CODE_ROOTS", str(HOME / "src")).split(os.pathsep)]
CLAUDE_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR", HOME / ".claude"))
COPILOT_DIR = Path(os.environ.get("COPILOT_HOME", HOME / ".copilot"))

MCP_FILES = [
    ("claude-code", "user/local", HOME / ".claude.json"),
    ("cursor", "user", HOME / ".cursor" / "mcp.json"),
    ("copilot-cli", "user", COPILOT_DIR / "mcp-config.json"),
]
PROJECT_MCP = [("claude-code", ".mcp.json"), ("cursor", ".cursor/mcp.json"),
               ("vscode", ".vscode/mcp.json")]
HOOK_FILES = [("claude-code", "user", CLAUDE_DIR / "settings.json"),
              ("cursor", "user", HOME / ".cursor" / "hooks.json")]
PROJECT_HOOKS = [("claude-code", ".claude/settings.json"),
                 ("claude-code", ".claude/settings.local.json"),
                 ("cursor", ".cursor/hooks.json")]
SKILL_DIRS = [("claude-code", "user", CLAUDE_DIR / "skills")]

out = csv.writer(sys.stdout)
out.writerow(["host", "user", "client", "scope", "kind", "file", "name", "detail"])
HOST, USER = socket.gethostname(), os.environ.get("USER", os.environ.get("USERNAME", "?"))


def emit(client, scope, kind, path, name, detail):
    out.writerow([HOST, USER, client, scope, kind, str(path), name, detail])


def load(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except FileNotFoundError:
        return None
    except (OSError, json.JSONDecodeError) as err:
        emit("?", "?", "error", path, "unreadable", str(err)[:120])
        return None


def servers(client, scope, path, block):
    if not isinstance(block, dict):
        return
    for name, cfg in block.items():
        if not isinstance(cfg, dict):
            continue
        if cfg.get("url"):
            detail = "url=" + str(cfg["url"])
        else:
            detail = "cmd=" + " ".join([str(cfg.get("command", ""))] + [str(a) for a in cfg.get("args", [])])
        emit(client, scope, "mcp", path, name, detail[:300])


def mcp_file(client, scope, path):
    data = load(path)
    if not isinstance(data, dict):
        return
    servers(client, scope, path, data.get("mcpServers") or data.get("servers"))
    for proj, pdata in (data.get("projects") or {}).items():  # Claude Code local scope
        if isinstance(pdata, dict):
            servers(client, "local:" + proj, path, pdata.get("mcpServers"))


def hook_commands(node):
    if isinstance(node, dict):
        for key, val in node.items():
            if key == "command" and isinstance(val, str):
                yield val
            else:
                yield from hook_commands(val)
    elif isinstance(node, list):
        for item in node:
            yield from hook_commands(item)


def hook_file(client, scope, path):
    data = load(path)
    if isinstance(data, dict) and "hooks" in data:
        for event, spec in data["hooks"].items() if isinstance(data["hooks"], dict) else []:
            for cmd in hook_commands(spec):
                emit(client, scope, "hook", path, event, cmd[:300])


def skills(client, scope, root):
    if not root.is_dir():
        return
    for skill_md in root.glob("*/SKILL.md"):
        try:
            digest = hashlib.sha256(skill_md.read_bytes()).hexdigest()
        except OSError as err:
            digest = "unreadable:" + str(err)[:60]
        emit(client, scope, "skill", skill_md, skill_md.parent.name, "sha256=" + digest)


for client, scope, path in MCP_FILES:
    mcp_file(client, scope, path)
for client, scope, path in HOOK_FILES:
    hook_file(client, scope, path)
for client, scope, root in SKILL_DIRS:
    skills(client, scope, root)
for root in CODE_ROOTS:
    if not root.is_dir():
        continue
    for dirpath, dirnames, _ in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in ("node_modules", ".git", ".venv")]
        here = Path(dirpath)
        for client, rel in PROJECT_MCP:
            if (here / rel).is_file():
                mcp_file(client, "project:" + str(here), here / rel)
        for client, rel in PROJECT_HOOKS:
            if (here / rel).is_file():
                hook_file(client, "project:" + str(here), here / rel)
        if (here / ".claude" / "skills").is_dir():
            skills("claude-code", "project:" + str(here), here / ".claude" / "skills")
  1. Run it per user, not as root. Paths resolve from the user's home directory. Set CODE_ROOTS to the platform's code folders, separated by : on macOS and Linux or ; on Windows. Verify: the pilot CSVs list the servers you saw in step 2 of pre-flight.
  2. Check the args column by hand. Some teams pass tokens as command-line arguments. The script records args. Search the merged file for token, key and secret, and redact before storing.
  3. Schedule it. Run daily through MDM. Upload each CSV to the restricted store.
  4. Merge and key. Key MCP rows on client plus command or URL, not on name. Names are user-chosen and can be anything. Key skills on the SHA-256.
  5. Answer the incident question. When a report names a server, package or skill, search the merged table by command, URL, package name or hash. Verify with a drill: pick a server from last week's data and time how long the answer takes.

Three failure modes and their fixes

Failure 1: the table misses plugin-bundled servers. Plugins can ship MCP servers and hooks inside their own folders. Fix: add ~/.claude/plugins/installed_plugins.json to the collector, or capture claude mcp list output on each run. Treat any server that appears there but not in the table as a gap.

Failure 2: code roots are wrong. Developers keep checkouts in many places. A repository outside CODE_ROOTS is invisible. Fix: add the paths your source-control client records, and compare repository counts with your Git host's clone data.

Failure 3: the files moved. A client update changes a path or a key. The collector reports nothing and looks clean. Fix: keep one canary entry per client on a test machine. If a run returns no canary row, the collector is broken, not the fleet clean.

Rollback and health check

The collector only reads files. To roll back, remove the MDM job and delete the stored CSVs under your retention policy.

Weekly health check:

  • Machines reporting in the last 24 hours, against machines known to run agents.
  • Canary rows present for every client.
  • Rows with kind=error, which mark unreadable or malformed configs worth a look.

With the table in place, the threat.cafe early-access list can be matched against it, so a newly flagged server or skill becomes a search, not a survey.

Sources

#inventory #mcp-servers #agent-skills #hooks #endpoint-management

All posts