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
~/srcor~/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
- Pick five pilot machines across macOS, Linux and Windows.
- On each, confirm the files exist where the map says. Run
claude mcp liston 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. - 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
- Deploy the collector. The script below reads the map's user-level files, then walks the code roots in
CODE_ROOTSfor project files. It records server names with their command or URL, hook commands, and a SHA-256 of each SKILL.md. It skipsenvandheadersblocks.
#!/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")
- Run it per user, not as root. Paths resolve from the user's home directory. Set
CODE_ROOTSto 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. - Check the args column by hand. Some teams pass tokens as command-line arguments. The script records args. Search the merged file for
token,keyandsecret, and redact before storing. - Schedule it. Run daily through MDM. Upload each CSV to the restricted store.
- 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.
- 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
- 5 Claude agent skills risks every CISO should know — Cloud Security Alliance (republished from Akto), 25 June 2026
- Securing the agentic endpoint — Palo Alto Networks, 17 February 2026
- Connect Claude Code to tools via MCP — Anthropic Claude Code docs, accessed 15 October 2026
- Explore the .claude directory — Anthropic Claude Code docs, accessed 15 October 2026
- Deploy managed settings — Anthropic Claude Code docs, accessed 15 October 2026
- Model Context Protocol — Cursor docs, accessed 15 October 2026
- Hooks — Cursor docs, accessed 15 October 2026
- Add and manage MCP servers in VS Code — Visual Studio Code docs, accessed 15 October 2026
- Using GitHub Copilot CLI — GitHub Docs, accessed 15 October 2026