TL;DR Write a skill when you own the words (repeated instructions, project workflows); write an MCP server when you own the tools (external APIs, databases, filesystems); wrap both in a plugin when you need to ship. These are three different interface contracts — conflating them is the root cause of most authoring errors.
The authoring landscape
Three distinct extension contracts coexist in 2026 AI agent tooling:
| Layer | What it is | Author | Interface |
|---|---|---|---|
| Skill | Markdown instructions + optional scripts | Prompt/workflow author | Natural-language activation or /slash |
| MCP server | JSON-RPC service exposing tools/resources/prompts | Developer | stdio or Streamable HTTP |
| Plugin | Bundle of skills + agents + hooks + MCP config | Developer | /plugin install marketplace |
Claude Code skills follow the Agent Skills open standard [1], now implemented by 35+ tools including Cursor, GitHub Copilot, JetBrains Junie, Gemini CLI, and OpenAI Codex [1]. MCP is an Anthropic-originated JSON-RPC protocol [2] with 10,000+ community servers as of mid-2026 [3]. Plugins are Claude Code’s distribution wrapper for all three [4].
Part 1 — Agent Skills
Directory layout
A skill is a folder containing one required file [1]:
my-skill/
├── SKILL.md # required
├── scripts/ # executable helpers
├── references/ # docs loaded on demand
└── assets/ # templates, data files
The folder name becomes the slash command (/my-skill). The frontmatter name field is a display label only — it does not change what you type, except for a plugin-root SKILL.md where no folder name exists [5].
Frontmatter spec
The Agent Skills standard defines [1]:
| Field | Required | Constraint |
|---|---|---|
name |
Yes | 1–64 chars, lowercase + hyphens, must match parent dir name |
description |
Yes | 1–1024 chars; the activation signal — write it like an API docstring |
license |
No | License name or bundled file reference |
compatibility |
No | Environment requirements (packages, tools, network) |
metadata |
No | Arbitrary key-value map |
allowed-tools |
No | Space-separated pre-approved tool names (experimental) |
Claude Code extends the standard with [5]:
| Field | Purpose |
|---|---|
when_to_use |
Extra activation hints; appended to description in context listing |
argument-hint |
Shown in autocomplete: [issue-number] or [filename] [format] |
arguments |
Named positional args for $name substitution |
disable-model-invocation |
true → user-only; removes skill from Claude’s context entirely |
user-invocable |
false → hides from / menu; Claude-only activation |
disallowed-tools |
Blocked tools while skill active; clears on next user message |
model |
Model override for this turn (reverts on next prompt) |
effort |
low / medium / high / xhigh / max override |
context |
fork → run in isolated subagent |
agent |
Which subagent type to use with context: fork |
paths |
Glob patterns; skill auto-activates only when working with matching files |
hooks |
Lifecycle hooks scoped to this skill |
shell |
bash (default) or powershell for ! blocks |
Invocation matrix
| Frontmatter | User /cmd |
Claude auto-invoke | In Claude’s context listing |
|---|---|---|---|
| (default) | ✓ | ✓ | name + description |
disable-model-invocation: true |
✓ | ✗ | hidden from Claude |
user-invocable: false |
✗ | ✓ | name + description |
Use disable-model-invocation: true for anything with side effects (deploy, commit, send-slack). Use user-invocable: false for ambient knowledge skills that shouldn’t appear as slash commands [5].
Dynamic context injection
The !`cmd` syntax runs shell commands before the skill reaches Claude — output is inlined in place [5]:
---
name: summarize-changes
description: Summarize uncommitted changes and flag risks. Use when the user asks what changed, wants a commit message, or asks to review their diff.
---
## Current diff
!`git diff HEAD`
Summarize the changes above in 2-3 bullets, then list risks (missing error handling, hardcoded values, missing tests).
Multi-line variant: fenced block opened with ` ```! . Rules: injection runs once, does not re-scan output, and ! must appear at line start or after whitespace — KEY=!cmd is left as literal text. Use ${CLAUDE_SKILL_DIR}` to reference bundled scripts portably [5].
Subagent forking
Add context: fork to run the skill in an isolated context. The rendered SKILL.md becomes the subagent’s prompt; it has no access to conversation history [5]:
---
name: pr-summary
description: Summarize a pull request
context: fork
agent: Explore
allowed-tools: Bash(gh *)
---
## PR context
- Diff: !`gh pr diff`
- Comments: !`gh pr view --comments`
- Files: !`gh pr diff --name-only`
Summarize this PR in 3 bullets: what changed, why, and any risks.
context: fork only makes sense for explicit task skills. A skill that contains only guidelines has no actionable task — the forked subagent receives the guidelines and exits without output.
Craft rules
- Description is the activation switch. It’s truncated at 1,536 chars in context; put the key use case and trigger phrases first. The agent matches descriptions to messages semantically — keyword density matters [5].
- Keep
SKILL.mdunder 500 lines. The full body stays in context for every subsequent turn after activation. Move reference material toreferences/*.mdand link fromSKILL.md[1]. - State, don’t narrate. “Summarize in 2-3 bullets” beats “When the user asks about their changes, provide a helpful summary that highlights…”
- Progressive disclosure. Agents load: (1) name + description (~100 tokens at startup), (2) full
SKILL.mdon activation (~<5000 tokens recommended), (3) supporting files on demand. Structure content at each layer [1].
Scoping
| Location | Scope | Path |
|---|---|---|
| Enterprise | All org users | Managed settings |
| Personal | All your projects | ~/.claude/skills/<name>/SKILL.md |
| Project | This repo | .claude/skills/<name>/SKILL.md |
| Plugin | Where plugin enabled | <plugin>/skills/<name>/SKILL.md |
Plugin skills are namespaced (/my-plugin:hello) — no collision with standalone skills. Project skills require workspace trust before allowed-tools takes effect [5].
Part 2 — MCP Server Authoring
The three primitives
MCP converts n×m integrations into n+m by standardising three primitive types [2]:
| Primitive | Consumer | Semantics | Mutable |
|---|---|---|---|
| Tools | LLM (invoked) | Execute actions, call APIs, run queries | ✓ |
| Resources | Host / LLM (read) | URI-addressed data, config, file content | ✗ |
| Prompts | User / host | Reusable templates, multi-step flows | — |
Decision heuristic: does the AI need to do something? → Tool. Does it need to know something? → Resource. Does the user need a consistent interaction pattern? → Prompt [6]. Resources and Prompts are the most-skipped primitives — and the biggest driver of over-using tool calls for context retrieval [7].
Transports
| Transport | When to use | Auth requirement |
|---|---|---|
stdio |
Local, single-client, spawned as child process | Process isolation |
Streamable HTTP + SSE |
Remote, multi-client, cloud | OAuth 2.1 + PKCE S256 |
Spec 2025-11-25 mandates OAuth 2.1 + PKCE, RFC 9728 Protected Resource Metadata, and RFC 8707 Resource Indicators for remote servers [3]. Stateless servers can now run behind a round-robin load balancer; stateful operations should mint explicit handles (a basket_id) that the model passes back as ordinary arguments [13].
Official SDKs
Python SDK ⭐ 23.2k — FastMCP decorator API [8]:
@app.tool()
async def get_weather(city: str) -> str:
"""Get current weather for a city. Use when the user asks about weather."""
return f"Weather in {city}: 16°C, partly cloudy"
TypeScript SDK ⭐ 12.6k — Zod schema, type-safe [8]:
server.tool(
"get_weather",
"Get current weather for a city. Use when the user asks about weather.",
{ city: z.string().describe("City name") },
async ({ city }) => ({ content: [{ type: "text", text: `...` }] })
);
The description string in both forms is what the LLM reads to decide whether to call the tool — treat it as API documentation [6].
Tool annotations
Four optional boolean hints in ToolAnnotations [9] [10]:
| Hint | Default | Meaning |
|---|---|---|
readOnlyHint |
false |
Tool does not modify state |
destructiveHint |
true |
Tool may destroy or overwrite data |
idempotentHint |
false |
Repeated identical calls produce the same result |
openWorldHint |
true |
Tool reaches outside local environment |
Defaults are deliberately cautious. Clients use hints for UI decisions (confirmation dialogs, parallelism) and Claude Code uses readOnlyHint: true to enable concurrent dispatch. Hints are not guarantees — a server can misrepresent them; clients cannot trust annotations from untrusted servers [9].
Tool design craft rules
- Description is the selection mechanism. Include trigger phrases (“use when…”), what the tool does, and what it returns. Name alone is insufficient [6].
- Always use strict JSON Schema. Set
additionalProperties: false. Use Zod or Pydantic — they generate the schema and validate inputs at the same time [3]. - Sanitize outputs before returning. Tool responses re-enter the LLM’s context. Strip instruction-like patterns from document bodies, scraped pages, webhook payloads, and error messages [11].
- Single-purpose servers. One server, one domain. Mixing database tools with email tools expands blast radius when one tool is compromised [12].
- Annotate destructive operations explicitly.
destructiveHint: true+readOnlyHint: falselets clients gate on human approval — even if these are already the defaults, stating them makes intent auditable [10].
Resource design craft rules
- URI-address everything:
config://,user://current/settings,file://pathfor consistent discovery [6]. - Include
mimeTypeso hosts render content correctly. - Never allow a tool call to mutate a resource directly — design separate write tools.
- If lists can change, set
"listChanged": truein capabilities and emitnotifications/tools/list_changed[13].
Security: non-negotiables
The full MCP security spec [14] documents seven attack classes. For server authors the most actionable:
- Treat all tool inputs as hostile. They arrive from an LLM, not a human. Validate with Zod/Pydantic; reject anything outside schema [14].
- No token passthrough. An MCP server MUST NOT accept tokens not explicitly issued for it and forward them downstream. This is the “confused deputy” anti-pattern [14].
- Scope minimization. Start with minimal scopes; elevate via targeted
WWW-Authenticatechallenges when privileged operations are first attempted. Never pre-request all scopes [14]. - Indirect prompt injection. Tool responses that contain document bodies, scraped pages, or search results can carry injected instructions. Sanitize before returning [15].
- Hash tool definitions on first approval. Re-validate schema hashes before every invocation. “Rug pull” attacks change a benign tool description post-approval to redirect agent behavior [11].
- Audit log every invocation. Redact secrets; rate-limit per client; integrate with SIEM for anomaly alerting [16].
- SSRF for remote servers. OAuth metadata URLs can redirect to internal resources. Block private IP ranges; enforce HTTPS; pin DNS resolution between check and use [14].
Testing
Use MCP Inspector to browse all exposed primitives, test tool calls with custom arguments, and inspect raw JSON-RPC traffic. It catches schema mismatches and logic errors before production [13]. The cyanheads/model-context-protocol-resources ⭐ 279 repo maintains client development guides and reference implementations [18].
Part 3 — Claude Code Plugins
Standalone vs plugin
Standalone .claude/ |
Plugin | |
|---|---|---|
| Skill name format | /hello |
/my-plugin:hello |
| Best for | Personal config, project-specific, rapid iteration | Sharing, distribution, versioned releases |
| Installation | Manual copy | /plugin install |
| Multi-project reuse | Duplicate manually | Install once |
| Namespace conflicts | Possible | Impossible (namespaced) |
Start standalone in .claude/ for rapid iteration; convert to a plugin when ready to share [4].
Plugin directory structure
my-plugin/
├── .claude-plugin/
│ └── plugin.json # manifest; ONLY this file lives here
├── skills/<name>/SKILL.md # → /my-plugin:<name>
├── agents/ # custom subagent definitions
├── hooks/hooks.json # event handlers
├── .mcp.json # bundled MCP server configs
├── .lsp.json # LSP server configurations
├── monitors/monitors.json # background file/log watchers
├── bin/ # executables injected into PATH
└── settings.json # defaults applied when plugin enabled
The .claude-plugin/ directory contains only plugin.json. Putting skills, agents, or hooks inside .claude-plugin/ is the most common structural mistake [4].
plugin.json schema
{
"name": "my-plugin",
"description": "...",
"version": "1.0.0",
"author": { "name": "Your Name" }
}
version is optional; omit it and the commit SHA becomes the version. Set it explicitly if you want user-controlled update gates [4].
Background monitors
Monitors watch logs, files, or external state and deliver each stdout line as a Claude notification [4]:
[{
"name": "error-log",
"command": "tail -F ./logs/error.log",
"description": "Application error log"
}]
Development workflow
# Iterate locally
claude --plugin-dir ./my-plugin
# Pick up changes without restart
/reload-plugins
# Load a pre-built archive (v2.1.128+)
claude --plugin-dir ./my-plugin.zip
# Validate before submitting
claude plugin validate
Two public marketplaces exist: claude-plugins-official (Anthropic-curated) and claude-community (third-party, review-gated). Submit to community review at claude.ai/settings/plugins/submit [4].
Cross-cutting rules
| Rule | Skills | MCP servers | Plugins |
|---|---|---|---|
| Description = interface | ✓ activation signal | ✓ tool selection signal | ✓ marketplace discovery |
| Progressive disclosure | 3-tier loading | capabilities declared on initialize |
— |
| Side-effect gates | disable-model-invocation |
destructiveHint + human approval |
— |
| Audit / observability | hooks | log every call | monitors |
| Scope minimization | narrow paths: + allowed-tools: |
minimal OAuth scopes | — |
| Distribution unit | project commit / plugin | server binary | /plugin install |
The common failure mode across all three: over-broad scope. A skill that activates on everything, a tool with no JSON Schema, a plugin that grants * permissions — all expand blast radius for free. Narrow the activation surface, validate inputs, annotate risk [16] [17].