Skip to main content
Glama
README.md
[简体中文](README.zh.md)

# paddock — Local filesystem workbench (MCP stdio server)

paddock gives agents a complete, constrained set of local file operations: text read/write, directory management, filename search, content search, and metadata queries — all operations are confined to configurable "paddock" directories, and out-of-bounds access is always rejected.

- **Zero runtime dependencies**: pure Node.js standard library, no `npm install` needed
- **MCP protocol**: line-delimited JSON-RPC 2.0 over stdio, loadable by any MCP client
- **dsh-ready**: ships a Cordis bridge plugin, one-line `bundle` integration with the harness
- **Read-only mode**: `--read-only` blocks all write operations in one shot
- **Large files & binaries**: streaming head/tail, byte slicing, base64 reads, binary sniffing
- **Search**: a self-built glob engine (`*` `**` `?` `{a,b}` `[abc]`) plus regex/fixed-string content search

---

## Quick start

```bash
# No dependency installation needed, run directly
node src/entry.js "C:/Users/me/projects"
```

Once started, the MCP session begins: JSON-RPC messages are read line by line from stdin, written line by line to stdout, and logs go only to stderr.

Connect from any MCP client (example with a standard MCP client config):

```json
{
  "mcpServers": {
    "paddock": {
      "command": "node",
      "args": ["/path/to/fs-mcp/src/entry.js", "C:/Users/me/projects"]
    }
  }
}
```

You can also run a smoke session directly to verify:

```bash
# Write an initialize request to stdin and observe the response
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' | node src/entry.js "C:/Users/me/projects"
```

## Installation

- **As an MCP server**: copy this directory, run `node src/entry.js <paddock dirs…>`, no build step.
- **As a dsh plugin**: see [docs/INTEGRATION.md](docs/INTEGRATION.md).
- Requirements: Node.js ≥ 18.17 (check with `node --version`).

### Installing in DSH

```bash
dsh plugin --profile demo add github:JohnXu22786/fs-mcp
```

- `demo` is a dsh profile: it is created automatically on first use, and the package is added to `dsh.profile.bundles`;
- The `cordis.patch.yml` inside the package defines the `paddock/bridge` plugin, which starts this MCP server within the dsh process and registers all 16 tools into `ctx.tools` after the handshake — no manual configuration needed;
- Removal:

```bash
dsh plugin --profile demo remove paddock
```

---

## Configuration

Configuration is merged as "defaults < config file < environment variables < command-line arguments"; paddock directories are unioned across all three sources.

### Command line

```
node src/entry.js [options] [paddock dirs…]

  --config <path>   config file (defaults to paddock.config.json in the working directory, loaded only if present)
  --read-only       read-only mode: blocks all write operations
  --zones <path>    additional paddock directories (repeatable)
  --verbose         debug logging (stderr)
  --version / --help
```

Positional arguments are treated as paddock directories. At least one paddock is required, otherwise startup fails (exit code 2).

### Config file (`paddock.config.json`)

```json
{
  "zones": ["C:/Users/me/projects", "C:/Users/me/data"],
  "readOnly": false,
  "limits": {
    "peekBytes": 1048576,
    "readBytes": 16777216,
    "sliceBytes": 65536,
    "grepBytes": 262144,
    "grepHits": 200,
    "findHits": 1000,
    "treeDepth": 6
  },
  "behavior": {
    "includeHidden": false
  }
}
```

A full example is in [examples/paddock.config.example.json](examples/paddock.config.example.json).

### Environment variables

| Variable | Description |
| --- | --- |
| `PADDOCK_CONFIG` | config file path (takes precedence over the default path) |
| `PADDOCK_ZONES` | paddock directories: a JSON array string (`["/a","/b"]`) or a single path |
| `PADDOCK_READ_ONLY` | `1` / `true` / `yes` / `on` count as enabled |
| `PADDOCK_VERBOSE` | same as above (`1` / `true` / `yes` / `on`) |

### limits semantics

| Key | Default | Effect |
| --- | --- | --- |
| `peekBytes` | 1 MiB | upper bound for `pdk_peek` full reads (head/tail are not limited by this, but an over-long single line is capped by the internal 8 MiB line-buffer limit) |
| `readBytes` | 16 MiB | upper bound for `pdk_read_bytes` base64 reads |
| `sliceBytes` | 64 KiB | upper bound for a single `pdk_slice` slice |
| `grepBytes` | 256 KiB | per-file scan limit for `pdk_grep` (`deep: true` can bypass; lines over 1 MiB are skipped wholesale) |
| `grepHits` | 200 | hit-count limit for `pdk_grep` |
| `findHits` | 1000 | result-count limit for `pdk_find` |
| `treeDepth` | 6 | default depth limit for `pdk_tree` |
| `treeEntries` | 20000 | total node limit for `pdk_tree` (truncated with a `truncated` marker when exceeded) |

---

## Tool interface

16 tools; write operations carry a `mutating` marker (gated by read-only mode). All path arguments must be absolute paths inside a paddock; relative paths are resolved against the process working directory and validated the same way.

### Read-only tools

| Tool | Parameters | Description |
| --- | --- | --- |
| `pdk_zones` | — | list paddocks (given paths + resolved real paths) |
| `pdk_ls` | `path`, `sortBy?` | list directory: entry type (file/dir/link), size, sorting, counts and totals |
| `pdk_tree` | `path`, `depth?`, `exclude?`, `includeHidden?` | recursive directory tree (JSON); overly deep directories are marked `cut: true`, node-limit overruns are marked `truncated` |
| `pdk_peek` | `path`, `head?`, `tail?` | read text files; head/tail stream the first/last N lines; binary and over-limit files are rejected |
| `pdk_read_many` | `paths[]` | batch text reads (1–64 files); a single file failure is folded without aborting |
| `pdk_slice` | `path`, `offset?`, `length?` | read large files by byte range; binary content returned as base64; `eof: true` when the slice reaches the end of the file |
| `pdk_read_bytes` | `path` | base64 read of binaries + MIME guessing (png/jpg/mp3/…) |
| `pdk_find` | `path`, `pattern`, `exclude?`, `includeHidden?` | recursive filename glob search |
| `pdk_grep` | `path`, `needle`, `mode?`, `caseSensitive?`, `glob?`, `exclude?`, `includeHidden?`, `deep?` | content search: regex (case-insensitive by default) or fixed string; line numbers + truncated snippets |
| `pdk_meta` | `path` | metadata: type/size/permissions/times; symlinks report their target |

### Write tools

| Tool | Parameters | Description |
| --- | --- | --- |
| `pdk_write` | `path`, `content` | write/overwrite (UTF-8), parent directories created automatically |
| `pdk_rewrite` | `path`, `edits[]`, `dryRun?` | precise replacement: `{oldText, newText, all?}` applied in order; fails on any miss; `dryRun` previews `-`/`+` changes; `newText` is treated literally (`$` sequences are never expanded) |
| `pdk_mkdir` | `path` | recursive directory creation, idempotent |
| `pdk_move` | `source`, `destination` | move/rename; refuses to overwrite an existing destination; cross-device copies + deletes automatically |
| `pdk_copy` | `source`, `destination` | copy files/directories; symlinks are not followed (links are recreated as-is) |
| `pdk_remove` | `path`, `recursive?` | delete; non-empty directories need `recursive: true`; paddock roots can never be deleted |

### Error model

Tool-level failures do not produce protocol errors; they return structured `isError: true` results:

```json
{
  "isError": true,
  "content": [{ "type": "text", "text": "[not-found] File does not exist: C:/…" }],
  "structuredContent": { "error": { "code": "not-found", "message": "File does not exist: C:/…" } }
}
```

Error codes: `config` `readonly` `validation` `fence` (out of bounds) `not-found` `conflict` `limit` `binary` `wrong-kind` `io` `internal` (fallback). `network` / `timeout` / `jsonrpc` appear only in the bridge client (the communication layer with the server subprocess). Error messages carry actionable follow-up suggestions (e.g. large files are pointed to `pdk_slice`).

---

## Security model

- **Paddock boundary**: every path passes "paddock" validation before any IO (see [docs/SECURITY.md](docs/SECURITY.md)).
- **Symlinks**: existing paths are `realpath`-resolved then validated; new paths validate by resolving the "nearest existing ancestor" and re-checking — links inside a paddock that point outside can never become a springboard.
- **Component-level checks**: string-prefix misjudgments such as `zone` vs `zone_extra` are impossible; win32 comparisons fold case.
- **Windows hardening**: rejects invalid path segments, trailing dots/spaces, and reserved device names (NUL/CON/COM1…).
- **Read-only mode**: with `--read-only` or config `readOnly: true`, all 6 write tools are gated.
- **Delete protection**: paddock root directories cannot be removed by `pdk_remove`.
- **Large files/binaries**: text-read limits, slice reads, base64, NUL-byte sniffing — the model is never slammed with gigantic or binary content.

---

## dsh integration

Pick either of the two ways; details in [docs/INTEGRATION.md](docs/INTEGRATION.md):

1. **Bundle bridge (recommended, zero dependencies)**: this package's `dsh.bundle.patch` points to `cordis.patch.yml`, and dsh merges the `paddock/bridge` plugin line into the config tree; the plugin starts the server subprocess inside the harness process and, after the handshake, registers all 16 tools into `ctx.tools` — model-side tool names look like `mcp__paddock__pdk_peek`.
2. **Official MCP client**: connect directly to `node src/entry.js <paddock>` with the `@deepseek-ai/dsh-mcp-client` config line.

## Development

```bash
npm test        # node --test: 100+ cases (4 symlink cases skip on win32 as they need developer mode)
npm run smoke   # sequential end-to-end smoke test (real subprocess + stdio session, 16 checks)
node src/entry.js --help
```

Test coverage: paddock escapes/symlink escapes/ancestor-chain protection, read-write round-trips, large-file and binary policies, glob and content search, three-source config merging, protocol engine, end-to-end subprocess sessions.

## License

MIT, see [LICENSE](LICENSE).