AI & LLM

Claude Code Skills, Hooks and MCP Servers Explained

Shannon AtkinsonSeptember 6, 202613 min read
Claude Code Skills, Hooks and MCP Servers Explained

Written against Claude Code 2.1.263, verified on 6 September 2026 by running the CLI and reading the official docs at code.claude.com. Claude Code ships fast; if a flag or field below does not exist on your version, run claude --version first, then check the current docs.

Overview

Claude Code out of the box is a competent generalist. It reads your files, runs your commands, and writes code. What it does not know is your procedure, your rules, or your systems.

There are three ways to give it those, and they are not interchangeable:

  • Skills teach it a procedure. "When someone asks for a deploy summary, do these five things in this order."
  • Hooks enforce a rule. "This edit does not happen, regardless of what you decided."
  • MCP servers extend its reach. "You can now read rows from this database."

Most of the friction I see comes from people reaching for the wrong one, writing a long CLAUDE.md paragraph begging Claude not to touch production, when what they wanted was a hook. This post covers all three properly, with the real file paths and a worked example each.

Prerequisites

  • Claude Code installed and signed in. Run claude --version to confirm.
  • A project folder with a git repo in it. Git is your undo button for everything below.
  • jq on your PATH if you want to copy the hook examples verbatim, the official examples parse hook JSON with it.

1. Which One Do You Actually Need

Before writing anything, ask what happens if Claude ignores it.

If the answer is "nothing much, it just does the task less well", you want a skill. Skills are instructions. Claude reads them and follows them, usually. A skill that says "always run the linter before committing" is a good skill and a bad guardrail.

If the answer is "we lose data, or push something broken to a live server", you want a hook. Hooks run outside the model's control. The docs are explicit: CLAUDE.md and auto memory are context, not enforced configuration, and to block an action regardless of what Claude decides you use a PreToolUse hook.

If the answer is "it cannot do the task at all because it has no access", you want an MCP server.

Start with the one that answers your actual problem; a mature project ends up with all three.

2. Skills: Teaching Claude a Procedure

A skill is a folder with a SKILL.md file in it. The folder name becomes the slash command you type. That is the whole format.

2.1 Where skills live

LocationPathApplies to
Personal~/.claude/skills/<skill-name>/SKILL.mdAll your projects
Project.claude/skills/<skill-name>/SKILL.mdThis project only
Plugin<plugin>/skills/<skill-name>/SKILL.mdWherever the plugin is enabled

There is also an enterprise level deployed through managed settings, which most readers here will never touch.

When names collide, personal overrides project. Plugin skills sit in their own plugin-name:skill-name namespace so they cannot conflict at all. Project skills load from .claude/skills/ in the directory you started Claude in and in every parent directory up to the repository root, so starting in a subfolder still picks up the root ones.

Claude Code watches these directories. Add or edit a SKILL.md and the change is picked up in the current session, no restart. The one case that needs a restart is creating a skills directory that did not exist when the session started.

2.2 The frontmatter

Every field in a skill's YAML frontmatter is optional. Only description is genuinely recommended, because that is what Claude reads to decide whether to load the skill on its own.

The fields Claude Code accepts, as of 2.1.263: name, description, when_to_use, argument-hint, arguments, disable-model-invocation, user-invocable, allowed-tools, disallowed-tools, model, effort, context, agent, background.

One catch worth knowing before you write a skill you plan to share: if you ever package the skill for claude.ai or the Skills API, only six of those fields are allowed, name, description, license, compatibility, metadata, allowed-tools. Anything else is a hard error at packaging time, not a silent ignore. If portability matters, stick to those six.

2.3 A worked example: the pre-deploy check

Here is a skill I actually use. Before I push a change to a self-hosted stack, I want the same four things checked every time, and I want them checked against the real files rather than from memory.

Create the folder:

mkdir -p ~/.claude/skills/predeploy-check

Then save this as ~/.claude/skills/predeploy-check/SKILL.md:

---
name: predeploy-check
description: Reviews a Docker Compose automation stack before deployment. Use when the user asks to deploy, ship, push to the server, or check whether a stack is ready.
allowed-tools: Read, Grep, Glob, Bash(git status), Bash(git diff *)
---

## Uncommitted changes

!`git status --short`

## Compose files in this repo

!`ls -1 *.yml *.yaml compose* 2>/dev/null`

## Instructions

Check the compose files and env files in this project against the four rules below.
Report each as PASS or FAIL with the offending line, then stop. Do not fix anything
unless the user asks.

1. No image is tagged `latest`. Every image has an explicit version.
2. No secret appears literally in a compose file. Values come from an env file
   or the shell.
3. Any env file with real credentials in it is listed in `.gitignore`.
4. Nothing in the uncommitted changes above touches a file whose name contains
   `prod` without the user having said the word "production" in this conversation.

Finish with one line: READY or NOT READY.

Two things are doing the work here.

The !`git status --short` lines use dynamic context injection. Claude Code runs the command and replaces the line with its output before Claude sees the skill content. So the instructions arrive with the real state of your working tree already inlined, rather than Claude guessing from whatever files happen to be open.

The allowed-tools line narrows what the skill can reach. It cannot write, it cannot run arbitrary Bash, only git status and git diff. A read-only skill is a skill you can invoke without thinking about it.

Invoke it with /predeploy-check, or just ask "is this ready to ship" and let the description field trigger it.

If a skill needs to run a bundled script, ${CLAUDE_SKILL_DIR} expands to the skill's own directory in both the body and in Bash rules inside allowed-tools, so the rule can match the exact command the body tells Claude to run and it goes through without a prompt. ${CLAUDE_PROJECT_DIR} works the same way and needs 2.1.196 or later.

3. Hooks: Enforcing a Rule

A hook is a handler that fires at a named point in Claude Code's lifecycle. It receives JSON on stdin describing what is about to happen, and it can say no.

Handlers are not limited to shell commands. As of 2.1.263 a hook can be a shell command, an HTTP endpoint, an MCP tool call, an LLM prompt, or a subagent. Start with shell commands; they are the ones you can debug.

3.1 The real event names

This is where most blog posts get it wrong, so here is the full list of hook events as documented, in lifecycle order:

SessionStart, Setup, InstructionsLoaded, UserPromptSubmit, UserPromptExpansion, MessageDisplay, PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionDenied, Notification, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, Stop, StopFailure, TeammateIdle, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, PreModelSwitch, PostModelSwitch, SessionEnd, Elicitation, ElicitationResult.

You will use four of them for a long time: PreToolUse to block, PostToolUse to react, SessionStart to load context, and Stop to check work before Claude hands back.

Not every event supports blocking, and the ones that do use different fields to express it. PreToolUse uses hookSpecificOutput.permissionDecision. Most of the rest use a top-level decision: "block" with a reason.

3.2 Where hooks live

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonSingle projectYes, commit it
.claude/settings.local.jsonSingle projectNo, gitignored
Plugin hooks/hooks.jsonWhere the plugin is enabledYes
Skill or subagent frontmatterScoped to that componentYes

Managed policy settings add an organization-wide level on top.

3.3 A worked example: protect the production compose file

The rule I want: Claude never edits docker-compose.prod.yml. Not after a persuasive argument, not because it decided the change was safe.

Save this as .claude/hooks/protect-prod.sh in the project and make it executable with chmod +x:

#!/bin/bash
# .claude/hooks/protect-prod.sh
# Blocks Write/Edit against production compose files.
input=$(cat)
file_path=$(jq -r '.tool_input.file_path // empty' <<<"$input")

# Windows paths arrive with backslashes; normalise before comparing.
file_path="${file_path//\\//}"

case "$file_path" in
  */docker-compose.prod.yml|*/compose.prod.yml|*/.env.production)
    jq -n '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "Production stack files are edited by a human, on the server, after a backup."
      }
    }'
    ;;
  *)
    exit 0  # no decision; the normal permission flow applies
    ;;
esac

Then wire it up in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-prod.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Four details that matter and that I got wrong the first time:

tool_input.file_path is always absolute. Claude Code expands ~ and relative paths before hooks run, so a hook matching on paths cannot be bypassed by spelling the same path differently. On Windows the path arrives with backslashes even under Git Bash, which is why the script normalises separators before the case.

Exit 0 with no output is not approval. It means "no decision", and the normal permission flow continues. A hook can deny; staying silent does not allow.

Exit code 2 also blocks, using your stderr text as the reason. It is the shorter path when you do not want to build JSON. On events that can block, exit 2 wins even over a JSON permissionDecision of "allow".

PreToolUse does not fire for @-referenced files. When you write @secrets.env in a prompt, Claude Code inserts the contents while building the prompt, no tool call, so no hook. To keep paths out of reach that way, use a Read deny rule in permissions instead.

If you want to react to a file changing on disk no matter what wrote it, FileChanged is the event, but it fires after the change and has no decision control, so it cannot block.

Run /hooks inside a session to see what is registered.

4. MCP Servers: Extending Reach

MCP is a protocol for exposing tools and data to a model. An MCP server is a small program, local process or remote HTTP endpoint, that advertises a set of tools Claude can then call.

The mental model that keeps people out of trouble: an MCP server is a set of credentials plus a set of verbs, handed to something that will use them without asking you first every time. Before adding one, answer the question "what can this do on my behalf, at worst".

4.1 The three scopes

ScopeLoads inShared with teamStored in
Local (default)Current project onlyNo~/.claude.json
ProjectCurrent project onlyYes, via version control.mcp.json in project root
UserAll your projectsNo~/.claude.json

Note the trap in the naming: MCP "local scope" lives in ~/.claude.json in your home directory, while general local settings live in .claude/settings.local.json in the project. Different files, similar words.

4.2 Adding one

The command shape is claude mcp add [options] <name> <commandOrUrl> [args...]. Verified from claude mcp add --help on 2.1.263:

# Remote HTTP server
claude mcp add --transport http notion https://mcp.notion.com/mcp

# Remote server with a bearer token
claude mcp add --transport http secure-api https://api.example.com/mcp \
  --header "Authorization: Bearer your-token"

# Local stdio server with an env var, shared with the team
claude mcp add --scope project --transport stdio airtable \
  --env AIRTABLE_API_KEY=YOUR_KEY \
  -- npx -y airtable-mcp-server

The -- matters. For stdio servers it separates Claude's own options from the command that runs the server; everything after it is passed through untouched.

Check what you have with claude mcp list, inspect one with claude mcp get <name>, and remove one with claude mcp remove <name> --scope <scope>. Inside a session, /mcp shows connection status and handles OAuth sign-in for servers that need it.

Project-scoped servers from .mcp.json are not connected to until you approve them, claude mcp list shows unapproved ones as pending. claude mcp reset-project-choices clears those decisions if you want to be asked again.

4.3 A worked example: a scoped filesystem server

The most useful first MCP server for an automation builder is also the most dangerous one if you get the scope wrong. A filesystem server pointed at ~/ can read every SSH key and browser profile you own. The same server pointed at one folder cannot.

Make the folder first, so the boundary is real:

mkdir -p ~/projects/automations/exports
claude mcp add --transport stdio exports \
  -- npx -y @modelcontextprotocol/server-filesystem ~/projects/automations/exports

Then verify inside a session with /mcp and ask Claude to list what it can see. If it can see more than that folder, the scope is wrong, fix it before you use it for anything.

I keep a short section in CLAUDE.md for every project listing which servers it uses, what each is for, and what it is explicitly not for. Three months later, that note is the difference between confidently removing a server and leaving it connected because you are not sure.

5. Slash Commands and CLAUDE.md, Briefly

Slash commands are skills. The older .claude/commands/*.md files still work the same way, but when a skill and a command share a name, the skill takes precedence. If you have an existing commands/ folder, there is no urgency to convert it; if you are starting fresh, write skills.

CLAUDE.md is the file Claude Code loads at the start of every session. It is the right home for static project conventions: the stack, the commands, the things that have bitten you. Write it as factual statements, "the deployment target is a Hetzner VPS", "this repo uses docker compose, never docker-compose", rather than as imperative system instructions, because text framed as out-of-band commands can trip prompt-injection defences and get surfaced to you instead of used as context.

Alongside it, auto memory is a second store that Claude writes itself from your corrections, per repository, shared across worktrees, and only the first 200 lines or 25 KB are loaded. .claude/rules/ scopes rules to specific file types rather than piling everything into one file.

Say it once more, because it is the thing people get wrong: both are context, not enforcement. If it must not happen, it is a hook.

Plugins are the packaging layer over all of this: a directory with a .claude-plugin/plugin.json manifest that bundles skills, agents, hooks and MCP servers together, versioned and installable, with skills namespaced as /plugin-name:skill. The docs' advice matches mine, start standalone in .claude/ while you iterate, convert to a plugin when you are ready to share, and test locally with --plugin-dir first.

6. How They Combine

A real project ends up looking like this. .mcp.json in the repo gives everyone who clones it the same read-only access to the staging database. .claude/settings.json carries two PreToolUse hooks: one blocking edits to production files, one blocking docker compose down -v because that volume flag is not undoable. .claude/skills/ holds three procedures, the pre-deploy check above, a workflow-export routine, and an incident-summary skill. CLAUDE.md explains the stack and lists which MCP servers exist and why.

The skill tells Claude what good looks like. The hook makes sure the bad thing cannot happen even when the skill is ignored. The MCP server means it does not have to guess at data it could just read.

That layering is the same discipline that makes self-hosted automation survivable, see security best practices for workflow automation for the same idea applied to n8n, and the n8n Docker Compose guide for the stack most of my own examples run against. If you want to know who is writing this and what I run, that is on the about page.

Once a month, open .claude/settings.json and CLAUDE.md and read them. Remove allow rules for things you no longer do. Remove MCP servers you have not used. Configuration you have not read in six months is not a guardrail, it is a guess.


The next step is delegation: what happens when one Claude Code session is not enough, and you want several working on different parts of the same project. That is subagents and agent teams.

I teach this end to end, with the files and the failure modes, in the free Claude Code for Builders course in the House of Loops classroom. It is open to every member.

S

Shannon Atkinson

House of Loops is a free community for people who would rather own their automation stack than rent it: n8n, Claude Code, AI agents, local models and the self-hosting underneath them, across 33 courses in the classroom.

Join Our Community