mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-20 11:33:08 +00:00
add(plugin): mcp-server-dev — skills for building MCP servers
Three skills guiding developers through MCP server design: - build-mcp-server: entry-point decision guide (remote HTTP vs MCPB vs local) - build-mcp-app: interactive UI widgets rendered in chat - build-mcpb: bundled local servers with runtime Includes reference files for scaffolds, tool design, auth (DCR/CIMD), widget templates, manifest schema, and local security hardening.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# Local MCP Security
|
||||
|
||||
An MCPB server runs as the user, with whatever permissions the manifest was granted. Claude drives it. That combination means: **tool inputs are untrusted**, even though they come from an AI the user trusts. A prompt-injected web page can make Claude call your `delete_file` tool with a path you didn't intend.
|
||||
|
||||
Defense in depth. Manifest permissions are the outer wall; validation in your tool handlers is the inner wall.
|
||||
|
||||
---
|
||||
|
||||
## Path traversal
|
||||
|
||||
The #1 bug in local MCP servers. If you take a path parameter and join it to a root, **resolve and check containment**.
|
||||
|
||||
```typescript
|
||||
import { resolve, relative, isAbsolute } from "node:path";
|
||||
|
||||
function safeJoin(root: string, userPath: string): string {
|
||||
const full = resolve(root, userPath);
|
||||
const rel = relative(root, full);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error(`Path escapes root: ${userPath}`);
|
||||
}
|
||||
return full;
|
||||
}
|
||||
```
|
||||
|
||||
`resolve` normalizes `..`, symlink segments, etc. `relative` tells you if the result left the root. Don't just `String.includes("..")` — that misses encoded and symlink-based escapes.
|
||||
|
||||
**Python equivalent:**
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
def safe_join(root: Path, user_path: str) -> Path:
|
||||
full = (root / user_path).resolve()
|
||||
if not full.is_relative_to(root.resolve()):
|
||||
raise ValueError(f"Path escapes root: {user_path}")
|
||||
return full
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command injection
|
||||
|
||||
If you spawn processes, **never pass user input through a shell**.
|
||||
|
||||
```typescript
|
||||
// ❌ catastrophic
|
||||
exec(`git log ${branch}`);
|
||||
|
||||
// ✅ array-args, no shell
|
||||
execFile("git", ["log", branch]);
|
||||
```
|
||||
|
||||
If you're wrapping a CLI, build the full argv as an array. Validate each flag against an allowlist if the tool accepts flags at all.
|
||||
|
||||
---
|
||||
|
||||
## Read-only by default
|
||||
|
||||
Split read and write into separate tools. Most workflows only need read. A tool that's read-only can't be weaponized into data loss no matter what Claude is tricked into calling it with.
|
||||
|
||||
```
|
||||
list_files ← safe to call freely
|
||||
read_file ← safe to call freely
|
||||
write_file ← separate tool, separate permission, separate scrutiny
|
||||
delete_file ← consider not shipping this at all
|
||||
```
|
||||
|
||||
If you ship write/delete, consider requiring a confirmation widget (see `build-mcp-app`) so the user explicitly approves each destructive call.
|
||||
|
||||
---
|
||||
|
||||
## Resource limits
|
||||
|
||||
Claude will happily ask to read a 4GB log file. Cap everything:
|
||||
|
||||
```typescript
|
||||
const MAX_BYTES = 1_000_000;
|
||||
const buf = await readFile(path);
|
||||
if (buf.length > MAX_BYTES) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `File is ${buf.length} bytes — too large. Showing first ${MAX_BYTES}:\n\n`
|
||||
+ buf.subarray(0, MAX_BYTES).toString("utf8"),
|
||||
}],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Same for directory listings (cap entry count), search results (cap matches), and anything else unbounded.
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
- **Config secrets** (`secret: true` in manifest): host stores in OS keychain, delivers via env var. Don't log them. Don't include them in tool results.
|
||||
- **Never store secrets in plaintext files.** If the host's keychain integration isn't enough, use `keytar` (Node) / `keyring` (Python) yourself.
|
||||
- **Tool results flow into the chat transcript.** Anything you return, the user (and any log export) can see. Redact before returning.
|
||||
|
||||
---
|
||||
|
||||
## Checklist before shipping
|
||||
|
||||
- [ ] Every path parameter goes through containment check
|
||||
- [ ] No `exec()` / `shell=True` — `execFile` / array-argv only
|
||||
- [ ] Write/delete split from read tools
|
||||
- [ ] Size caps on file reads, listing lengths, search results
|
||||
- [ ] Manifest permissions match actual code behavior (no over-requesting)
|
||||
- [ ] Secrets never logged or returned in tool results
|
||||
- [ ] Tested with adversarial inputs: `../../etc/passwd`, `; rm -rf ~`, 10GB file
|
||||
@@ -0,0 +1,132 @@
|
||||
# MCPB Manifest Schema
|
||||
|
||||
Every `.mcpb` bundle has a `manifest.json` at its root. The host validates it before install.
|
||||
|
||||
---
|
||||
|
||||
## Top-level fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | ✅ | Unique identifier. Lowercase, hyphens only. Shown in settings. |
|
||||
| `version` | string | ✅ | Semver. Host compares for update prompts. |
|
||||
| `description` | string | ✅ | One line. Shown in the install prompt. |
|
||||
| `entry` | object | ✅ | How to launch the server — see below. |
|
||||
| `permissions` | object | ✅ | What the server needs — see below. |
|
||||
| `config` | object | — | User-settable values surfaced in settings UI. |
|
||||
| `icon` | string | — | Path to PNG inside the bundle. 256×256 recommended. |
|
||||
| `homepage` | string | — | URL shown in settings. |
|
||||
| `minHostVersion` | string | — | Refuse install on older hosts. |
|
||||
|
||||
---
|
||||
|
||||
## `entry`
|
||||
|
||||
```json
|
||||
{ "type": "node", "main": "server/index.js" }
|
||||
```
|
||||
|
||||
| `type` | `main` points at | Runtime resolution |
|
||||
|---|---|---|
|
||||
| `node` | `.js` or `.mjs` file | `runtime/node` if present, else host-bundled Node |
|
||||
| `python` | `.py` file | `runtime/python` if present, else host-bundled Python |
|
||||
| `binary` | executable | Run directly. Must be built per-platform. |
|
||||
|
||||
**`args`** (optional array) — extra argv passed to the entry. Rarely needed.
|
||||
|
||||
**`env`** (optional object) — static env vars set at launch. For user-configurable values use `config` instead.
|
||||
|
||||
---
|
||||
|
||||
## `permissions`
|
||||
|
||||
```json
|
||||
{
|
||||
"filesystem": { "read": true, "write": false },
|
||||
"network": { "allow": ["localhost:*", "127.0.0.1:*"] },
|
||||
"process": { "spawn": false }
|
||||
}
|
||||
```
|
||||
|
||||
### `filesystem`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `false` or omitted | No filesystem access beyond the bundle itself |
|
||||
| `{ "read": true }` | Read anywhere the OS user can |
|
||||
| `{ "read": true, "write": true }` | Read and write |
|
||||
|
||||
There's no path scoping at the manifest level — scope in your code. The manifest permission is a coarse consent gate, not a sandbox.
|
||||
|
||||
### `network`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `false` or omitted | No network (most local-first servers) |
|
||||
| `{ "allow": ["host:port", ...] }` | Allowlisted destinations. `*` wildcards ports. |
|
||||
| `true` | Unrestricted. Heavy scrutiny — explain why in `description`. |
|
||||
|
||||
### `process`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `false` or omitted | Can't spawn child processes |
|
||||
| `{ "spawn": true }` | Can spawn. Needed for wrapping CLIs. |
|
||||
| `{ "spawn": true, "allow": ["git", "ffmpeg"] }` | Spawn only allowlisted binaries |
|
||||
|
||||
---
|
||||
|
||||
## `config`
|
||||
|
||||
User-editable settings, surfaced in the host UI. Each key becomes an env var: `MCPB_CONFIG_<UPPERCASE_KEY>`.
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"rootDir": {
|
||||
"type": "string",
|
||||
"description": "Directory to expose",
|
||||
"default": "~/Documents"
|
||||
},
|
||||
"maxFileSize": {
|
||||
"type": "number",
|
||||
"description": "Skip files larger than this (MB)",
|
||||
"default": 10,
|
||||
"min": 1,
|
||||
"max": 500
|
||||
},
|
||||
"includeHidden": {
|
||||
"type": "boolean",
|
||||
"description": "Include dotfiles in listings",
|
||||
"default": false
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "Optional API key for the sync feature",
|
||||
"secret": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`type`** — `string`, `number`, `boolean`. Enums: use `string` with `"enum": ["a", "b", "c"]`.
|
||||
|
||||
**`secret: true`** — host masks the value in UI and stores it in the OS keychain instead of a plain config file.
|
||||
|
||||
**`required: true`** — host blocks server launch until the user sets it. Use sparingly — a server that won't start until configured is a bad first-run experience.
|
||||
|
||||
---
|
||||
|
||||
## Minimal valid manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hello",
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal MCPB server.",
|
||||
"entry": { "type": "node", "main": "server/index.js" },
|
||||
"permissions": {}
|
||||
}
|
||||
```
|
||||
|
||||
Empty `permissions` means no filesystem, no network, no spawn — pure computation only. Valid, if unusual.
|
||||
Reference in New Issue
Block a user