JohnXu22786
fs-mcp
paddock: constrained local filesystem MCP server for agents — file read/write/search confined to configurable zones, zero runtime dependencies, installable as a dsh plugin bundle.
- Stars
- 0
- Language
- JavaScript
- Created
- Aug 16, 2026
- Updated
- Aug 16, 2026
Introduction
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 installneeded - MCP protocol: line-delimited JSON-RPC 2.0 over stdio, loadable by any MCP client
- dsh-ready: ships a Cordis bridge plugin, one-line
bundleintegration with the harness - Read-only mode:
--read-onlyblocks 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
# 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):
{
"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:
# 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.
- Requirements: Node.js ≥ 18.17 (check with
node --version).
Installing in DSH
dsh plugin --profile demo add github:JohnXu22786/fs-mcp
demois a dsh profile: it is created automatically on first use, and the package is added todsh.profile.bundles;- The
cordis.patch.ymlinside the package defines thepaddock/bridgeplugin, which starts this MCP server within the dsh process and registers all 16 tools intoctx.toolsafter the handshake — no manual configuration needed; - Removal:
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)
{
"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.
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:
{
"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).
- 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
zonevszone_extraare 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-onlyor configreadOnly: 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:
- Bundle bridge (recommended, zero dependencies): this package's
dsh.bundle.patchpoints tocordis.patch.yml, and dsh merges thepaddock/bridgeplugin line into the config tree; the plugin starts the server subprocess inside the harness process and, after the handshake, registers all 16 tools intoctx.tools— model-side tool names look likemcp__paddock__pdk_peek. - Official MCP client: connect directly to
node src/entry.js <paddock>with the@deepseek-ai/dsh-mcp-clientconfig line.
Development
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.