srcbridge
by kerwinkao
README.md
# srcbridge
A sandboxed file-access MCP server for Claude Desktop. Deliberately **not**
shaped like the official filesystem server: different tool names, a different
contract, and a strong bias toward failing loudly instead of returning
something that merely *looks* complete.
MCP (Model Context Protocol) is how Claude Desktop reaches local tools. This
server is the piece that gives Claude read and write access to a set of
directories you choose, and nothing outside them.
Node-only, ESM, no build step. Three dependencies (`@modelcontextprotocol/sdk`,
`isomorphic-git`, `diff`) and **zero shell execution**.
## Why this exists
It started as a replacement when the official
`@modelcontextprotocol/server-filesystem` stopped working client-side, but the
reason it stayed is different: an LLM working through a file API cannot tell a
truncated answer from a complete one, and cannot tell "no matches" from "never
searched". Every design decision here follows from that.
The full reasoning for each version lives in [`CHANGELOG.md`](CHANGELOG.md);
each entry records the real incident that prompted it.
## What you get that a thin `fs` wrapper does not
- **An undo for every destructive write.** The previous version is snapshotted
first, and a snapshot failure aborts the write -- a write with no undo is
worse than a failed write.
- **Writes verified against the disk.** Atomic temp + rename, then the content
is hashed back off disk and the sha1 reported, so a timed-out call can be
told apart from a lost one.
- **Announced limits.** Truncation, hit caps, skipped directories, binary and
non-UTF-8 files are all reported -- so an empty result can be distinguished
from an unsearched one.
- **All-or-nothing multi-point edits.** `edit_batch` validates every anchor in
memory before anything is written, and rolls back if a write fails partway.
- **Anchor mismatches that explain themselves.** A failed edit names the
diverging character and its code point rather than just saying "not found".
- **Word documents as text.** `.docx` becomes Markdown with tables and list
numbering intact, plus a heading outline and a document-aware grep.
- **Legacy encodings refused, not mangled.** A Big5/GBK file is reported as
such instead of being returned as mojibake and written back corrupted.
- **Read-only git.** Status, diff and history, with no write path anywhere in
the codebase.
- **Secret-like files are refused, not served.** `.env`, private keys,
keystores and credential stores never enter the model's context on any
read, edit, overwrite, diff or history path; grep skips them and says how
many. Template files (`.env.example` and friends) stay readable.
## Design rules
These are the contract. They are repeated at the top of `server.mjs` and are
the thing to check any change against.
1. **Zero shell execution.** `node:fs` plus pure-JS libraries only.
2. **Every path resolves inside a root** given on argv. No escapes.
3. **Never let a partial result look complete.** All truncation, capping and
skipping is announced in the output.
4. **Writes are atomic** (temp + rename, file mode preserved) **and verified**
by hashing the content back off disk.
5. **Git is read-only.** Status, diff and log. No commit/add/push code path
exists, and none will.
6. **Never hard-delete a user's file.** The server may garbage-collect its own
artifacts (expired auto-snapshots, stale temp files); nothing else.
## Install
```bash
git clone https://github.com/kerwinkao/srcbridge.git
cd srcbridge
npm install
```
Then add it to `claude_desktop_config.json` and restart Claude Desktop. Every
argument after the script path is an allowed root:
```json
"srcbridge": {
"command": "node",
"args": [
"C:\\path\\to\\srcbridge\\server.mjs",
"C:\\projects",
"\\\\wsl$\\Ubuntu\\home\\<user>"
]
}
```
Roots may live on different volumes; cross-volume moves fall back to
copy + verify + source-to-trash rather than failing with `EXDEV`.
Optional environment variables:
- `SRCBRIDGE_SKIP_DIRS=dirA,dirB` — additional directory names to skip during
recursive walks, on top of `node_modules`, `.git`, `.srcbridge-trash`,
`dist`, `build`, `.expo`, `coverage`, `android`, `ios`.
- `SRCBRIDGE_SECRET_GLOBS=a,b` — additional basename globs (`*` only) treated
as secret-like, on top of `.env`, `.env.*`, `*.pem`, `*.key`, `*.p12`,
`*.pfx`, `*.jks`, `*.keystore`, `*.ppk`, `id_rsa*`, `id_dsa*`, `id_ecdsa*`,
`id_ed25519*`, `*.secret`, `secrets.json/.yaml/.yml`, `credentials.json`,
`.netrc`, `.pgpass`, `.git-credentials`, `.npmrc`, `.htpasswd`. Basenames
ending in `.example`, `.sample`, `.template` or `.dist` are never treated
as secret.
- `SRCBRIDGE_ALLOW_SECRETS=1` — disables the secret guard for the whole
server. The startup banner prints `SECRET GUARD OFF` while this is set.
## Tools
**Read** — `read_source`, `read_sources`, `read_bytes`, `list_entries`,
`tree_view`, `stat_source`, `list_workspaces`
**Search** — `find_sources` (paths), `grep_sources` (contents),
`grep_identifier` (one concept across camelCase / PascalCase / snake_case /
SCREAMING_SNAKE / kebab-case), `grep_document` (inside `.docx`)
**Write** — `write_source`, `write_bytes`, `edit_source`, `edit_batch`,
`edit_all` (replace every occurrence in one file, but only when the count you
declare is the count the file has), `move_lines` (cut a line range out of one
file into another, byte-for-byte, optionally indented on the way; the
primitive a module split needs), `make_folder`, `move_source`,
`discard_source`
**Word documents** — `read_document` (`.docx`/`.dotx` → Markdown, with
`outline=true` for a heading index), `grep_document`
**Git (read-only)** — `diff_changes` (workdir vs HEAD, or any `from`..`to` commit pair), `log_changes`, `read_source_at` (file content at any commit)
**Housekeeping** — `trash_status`
**Auditing & counting** — `checksum_sources` (one-call sha1 manifest of a directory), `count_source` (exact CJK/word/char statistics for manuscript length governance)
Each tool's own description carries its traps inline, because a tool
description is re-injected into the model's context every turn while a protocol
document read mid-session is not.
## Operating notes
Things that have actually caused wrong conclusions, in rough order of how often:
- **An empty search result is not proof of absence.** Read the `scanned N`
line and every announced cap before concluding anything. (Until v1.8.0 a
`root` that was a file or did not exist returned `scanned 0` and no hits —
indistinguishable from a real miss. A file root now searches that file and
says so; a missing root is an error.)
- **When an edit anchor fails to match, suspect your own string first.** Since
v1.4.0 the mismatch error carries a `DIAGNOSIS` block naming the diverging
character and its code point. Never `\u`-escape CJK on the way in — type the
characters directly and copy them verbatim out of `read_source`. Escapes are
for genuinely invisible characters only (zero-width, variation selectors,
NBSP).
- **`replace` has no guard that `find` has.** A mistyped anchor fails loudly; a
mistyped replacement lands silently. Since v1.5.0 every applied edit echoes
the landed text back with the changed lines marked — that echo is the only
thing standing between a typo and the disk. Since v1.13.0 it also names every
CJK character the file did not contain before the edit, with the nearest one
it does contain (a miscounted `\u` code point lands a few positions from the
intended character, which is almost always already in the file). Read it.
- **A timed-out call tells you nothing about whether the write happened.**
Use `stat_source(sha1=true)` and compare against the sha1 the write reported.
Re-running blindly is how a half-applied state gets created.
- **Use `edit_batch` whenever edits are linked.** Validation runs fully in
memory first; any miss aborts with the edit number named and nothing written.
- **Use `find_to` for long stretches** instead of pasting thousands of
characters into `find`. Two short anchors, and `preview` reports the span
size before you commit to it.
## Undo
Every destructive write snapshots the previous version into
`<root>/.srcbridge-trash/autosnap/` first; a snapshot failure aborts the write,
because a write with no undo is worse than a failed write. The newest 5
snapshots per file are kept and older ones garbage-collected.
`discard_source` is a soft delete into `<root>/.srcbridge-trash/` and is
**never** pruned. `trash_status` reports usage per root. Restoring anything is
just `move_source`.
## Development
```bash
node selftest.mjs
```
The selftest drives every tool in-process against a sandbox root — no MCP
transport, no Claude Desktop restart per change. The assertion count is
deliberately not written down anywhere; the criterion is "run it and see all
green".
After changing `server.mjs`, `office.mjs` or `selftest.mjs`, all three steps
are required: run the selftest green → restart Claude Desktop → let the client
reload the tool definitions. Skipping any one of them means you are testing
something other than what is running.
Fixtures under `__selftest/` encode line endings, BOMs and legacy encodings on
purpose. `.gitattributes` disables all text conversion for that reason — do not
"fix" a fixture that looks malformed.
### Structure (v1.11.0)
```
server.mjs VERSION, tool registry, dispatcher, MCP main (~200 lines)
lib/core.mjs limits, sandbox roots, secret guard, fidelity gates,
verified atomic writes + undo, path policy, defineTools
lib/editengine.mjs anchor diagnosis / locate / span / echo, matchers (pure)
lib/scan.mjs recursive walk, announced skips, the shared text scan
lib/gitro.mjs read-only git helpers
tools/*.mjs one module per tool group (read, docs, write, fs,
search, git, audit); each exports defineTools(defs, runs)
office.mjs .docx parsing
selftest.mjs the in-process runner (see above)
```
The shape that matters:
- A tool module holds **`defs`** (what the model sees: name, description,
input schema, and a `paths` declaration naming every parameter that is a
path and with what intent — `read` / `edit` / `overwrite` / `diff` /
`history` / `path`; `?` optional, `[]` list) next to **`runs`** (one
handler per tool, `(args, pth) => string`). `defineTools` joins them by
name and throws on any mismatch when the module loads.
- **The dispatcher** in `server.mjs` resolves every declared path into the
sandbox and applies the secret guard for its intent BEFORE a handler runs.
`pth` is what the handler receives. Handlers never resolve or guard a path
themselves; if you find one that does, that is a bug, not a style choice.
- Cross-module checks at load: no duplicate tool names, every `paths` entry is
well-formed and present in its schema, and every schema property that is a
path by name is declared. All import-time errors, so `node selftest.mjs`
dies on its import line rather than the Desktop failing later.
- Adding a tool = one entry in `defs`, one in `runs`, in the module it
belongs to. Nothing in `server.mjs` changes.
The split itself was done with `move_lines` (v1.10.0) — no line was re-typed
— and the selftest did not change. Before splitting `selftest.mjs`, add a
`--only <section>` filter to the runner; that removes the actual pain without
creating a second harness.
## Versioning
`CHANGELOG.md` is the version history and the record of design decisions,
including the ones that were deliberately *not* taken. It starts at v1.0.0; the
v0.x series was a single day of unstable design and its load-bearing reasoning
lives in code comments instead.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues