personal-mcp-bridge
README.md
# personal-mcp-bridge
A minimal, read-only bridge that lets an MCP client (or any HTTP caller) browse
a few **allowlisted** local directories safely. Every tool is read-only; there
is no write, edit, delete, or execute path anywhere in the surface.
Tools are available through the MCP endpoint `POST /mcp`, the localhost-only
compatibility dispatcher `POST /call`, and dedicated HTTP endpoints where noted:
- `list_roots` / `GET /roots` - list the directories you allowlisted, by alias
- `list_files` / `GET /files` - list one allowlisted directory
- `read_file` / `GET /read` - read one bounded file inside an allowlisted root
- `read_file_range` / `GET /read-range` - read a bounded line range
- `tail_file` / `GET /tail` - read the end of a bounded file
- `read_multiple_files` - read several bounded files through MCP or `/call`
- `get_file_info` / `GET /file-info` - inspect one allowlisted path's metadata
- `search` / `GET /search` - search text files under an allowlisted root
It is intentionally small.
`read_file`, `read_multiple_files`, and `search` also understand
PDF/DOCX/PPTX/XLSX, citation-prefixed (e.g. `[p.3]`, `[Sheet1!2-5]`), when you
install the optional `documents` extra (see below). Still read-only, and it
degrades gracefully: without the extra, those formats are simply not there.
Read output is character-bounded: `read_file`/`search` accept an optional
`max_chars` (capped by `BRIDGE_READ_MAX_CHARS`) and report the applied `limits`.
## What this is not
This is **not** a full personal automation layer. It does not run agents, write
files, run shell commands, drive a browser, or keep any memory, audit, or cache
database. It is an alpha, read-only file bridge with safe defaults. If you came
looking for a do-everything assistant runtime, this is the deliberately boring,
auditable subset.
## Safety model in one paragraph
Fail closed. With no roots configured, every request is blocked. Paths are
relative-only with no traversal, no drive letters, and no symlink escapes.
Common secret, runtime, cache, and generated paths (`.env`, token/secret/
credential-like files, `runtime/settings.json`, `.git`, virtualenvs,
`node_modules`, caches) are excluded from listing, reads, and search. In
public/tunnel mode the bridge refuses to start without a strong token, refuses
tokens passed in the URL, refuses the generic `/call` endpoint for any
forwarded/remote request, and never emits an absolute local path. See
[SECURITY.md](SECURITY.md) and [THREAT_MODEL.md](THREAT_MODEL.md).
## Install
Requires Python 3.10+.
```bash
python -m pip install -e .
# or, just install the runtime deps:
python -m pip install starlette uvicorn
```
To also read PDF/DOCX/PPTX/XLSX files, install the optional `documents`
extra (pypdf, python-docx, python-pptx, openpyxl). The base install stays
tiny without it:
```bash
python -m pip install -e ".[documents]"
```
## Run the demo (no real files touched)
The demo allowlists only the bundled synthetic `demo/mock_data` directory and
exercises all three tools in-process:
```bash
python demo/run_demo.py
```
You will see `list_roots`, a file read, a search hit on mock data, and a
traversal attempt being refused.
## Run the server against your own files
Allowlist one or more directories, then start the bridge on loopback:
```bash
export BRIDGE_ROOTS="notes=/path/to/notes;docs=/path/to/work/docs"
python -m personal_mcp_bridge
# serving on http://127.0.0.1:8787
```
Then:
```bash
curl http://127.0.0.1:8787/roots
curl "http://127.0.0.1:8787/files?root=notes&path=."
curl "http://127.0.0.1:8787/read?root=notes&path=welcome.md&max_chars=4000"
curl "http://127.0.0.1:8787/read-range?root=notes&path=welcome.md&start_line=1&line_count=20"
curl "http://127.0.0.1:8787/tail?root=notes&path=welcome.md&last_lines=20"
curl "http://127.0.0.1:8787/file-info?root=notes&path=welcome.md"
curl "http://127.0.0.1:8787/search?root=notes&q=budget&max_chars=4000"
```
On loopback with no token set, local calls are allowed for convenience. To
require a token even locally, set `BRIDGE_TOKEN` and send
`Authorization: Bearer <token>`.
## Runtime limits
Read output uses character caps for schema-facing text; request bodies and
search-file scanning use byte caps. Restart the bridge and reconnect/re-register
the MCP connector after changing any of these, since some clients cache schemas.
| Variable | Default | Purpose |
| --- | ---: | --- |
| `BRIDGE_READ_MAX_CHARS` | `128000` | Maximum returned characters for read/search tools. |
| `BRIDGE_SEARCH_MAX_FILE_BYTES` | `1048576` | Largest file searched/read by helpers that scan full files. |
| `BRIDGE_MAX_MATCHES` | `200` | Maximum search matches before truncation. |
| `BRIDGE_HTTP_MAX_BODY_BYTES` | `1000000` | Maximum accepted JSON request body size. |
| `BRIDGE_DOC_MAX_FILE_BYTES` | `26214400` | Largest PDF/DOCX/PPTX/XLSX accepted for parsing. |
| `BRIDGE_XLSX_MAX_SHEETS` | `20` | Sheets read per workbook. |
| `BRIDGE_XLSX_MAX_ROWS` | `2000` | Rows read per sheet. |
| `BRIDGE_XLSX_MAX_COLS` | `50` | Columns read per row. |
| `BRIDGE_LIST_FILES_MAX_ENTRIES` | `1000` | Maximum entries returned by `list_files`; results beyond this are truncated. |
| `BRIDGE_RATE_LIMIT_PER_MINUTE` | `0` (disabled) | Per-peer HTTP rate limit. In public/tunnel mode a finite default (600/min) applies if unset; setting it to `0` is refused at startup. |
| `BRIDGE_DOC_MAX_ARCHIVE_MEMBERS` | `1024` | OOXML preflight: maximum member count. |
| `BRIDGE_DOC_MAX_UNCOMPRESSED_BYTES` | `134217728` | OOXML preflight: maximum total uncompressed size. |
| `BRIDGE_DOC_MAX_MEMBER_BYTES` | `33554432` | OOXML preflight: maximum single-member uncompressed size. |
| `BRIDGE_DOC_MAX_COMPRESSION_RATIO` | `100` | OOXML preflight: maximum compression ratio per member. |
Compatibility aliases `BRIDGE_MAX_READ_CHARS`, `BRIDGE_MAX_READ_BYTES`, and
`BRIDGE_MAX_FILE_BYTES` are still accepted for existing setups.
`read_file` streams only the bytes it will return, and `read_file_range` and
`tail_file` stream a bounded window from a fixed-size chunk reader, so they
work on files of any size, including files that are one enormous line (or have
no newlines at all), without scaling peak memory to the file size. Requests
bodies are streamed and bounded too: consumption stops with `413` the moment
more than `BRIDGE_HTTP_MAX_BODY_BYTES` has been received, and `Content-Length`
is not trusted as the only enforcement. Documents are the exception at the
parse layer: their third-party libraries read whole files, so oversized ones
are refused before parsing (`BRIDGE_DOC_MAX_FILE_BYTES`).
## HTTP and error behavior
- `POST /mcp` returns `200` for JSON-RPC responses and `202 Accepted` with no
body for *valid* notifications (a well-formed envelope with no `id`), as the
MCP Streamable HTTP transport requires. A malformed object is not a
notification: it receives a JSON-RPC error response (`-32600`/`-32602`), and
an empty HTTP body returns `400 invalid-json`. A `notifications/*` method
carrying an `id` is an error (`-32600`), never a silent 202.
- Error responses use stable codes, grouped into categories, rather than raw
exception text:
- **Tool execution** (including `read_multiple_files` per-file results):
`path-excluded`, `file-not-found`, `not-a-directory`, `invalid-argument`,
`permission-denied`, `unknown-root`, `archive-rejected`, `internal-error`.
- **HTTP transport / input**: `invalid-json`, `invalid-content-length`,
`request-body-too-large`, `json-object-required`,
`unexpected-json-rpc-response`.
- **Authentication / exposure**: `token-in-url-refused`, `invalid-token`,
`host-not-allowed`, `origin-not-allowed`, `generic /call is localhost-only`.
- **Rate limit**: `rate-limited` (HTTP 429 with `Retry-After`).
`POST /call` uses `invalid-argument` for invalid envelopes and
`unknown-tool` for unknown tools. Unexpected failures are logged with the
exception type, stack frames, and an incident id (never the raw exception
message, which may embed tokens or paths) and surfaced only as
`internal-error`, so an OS-level exception can never leak an absolute local
path.
- JSON bodies are parsed strictly: `NaN`/`Infinity`/`-Infinity` are rejected
and parser errors map to `400 invalid-json` (never an unhandled 500). Bodies
accumulate into one bounded buffer, so a many-chunk request stays close to
the configured limit in memory. A JSON-RPC response object arriving on
`POST /mcp` (this server never issues server-to-client requests) is rejected
with `400 unexpected-json-rpc-response` rather than a misleading `-32600`
with HTTP 200.
- Filesystem names that Python decodes with `surrogateescape` (a POSIX
filename with undecodable bytes, e.g. `b"bad-\xff.txt"` -> `"bad-\udcff.txt"`)
are never surfaced in JSON, because a lone surrogate cannot be encoded to
strict UTF-8. Discovery (`list_files`, `search`) skips such entries so one
undecodable filename does not break a listing; direct reads of such a path
return the stable `path-excluded` error; and `INCLUDE_LOCAL_PATHS=1` debug
output omits any unsafe local path. The same `text_safety` helper backs the
incoming-JSON lone-surrogate rejection.
- Tool arguments are validated server-side against each tool's schema, and
`POST /call` uses the same exact-type validation, including its envelope:
`tool` must be a non-empty string and `args` must be an object when
present — no `str()` coercion and no collapsing of falsy `args`. MCP
`tools/call.arguments` is optional (omitted means `{}`); falsy values are
rejected. Missing required properties, unknown properties, wrong types (a
string is not a boolean, a boolean is not an integer), out-of-range integers
(e.g. `start_line` above `1000000`), over-length strings (paths capped at
1024 chars), and bad array items are refused with an `invalid-params` error.
A tool that runs and fails (missing file, excluded path, unknown root) stays
a normal MCP result with `isError: true`.
- `read_multiple_files` treats `max_chars` as an *aggregate caller-visible
payload-string budget*, not a serialized JSON or HTTP response cap. It
counts the requested path, the generated reference, the error code, and the
file content as Python Unicode characters, plus a fixed conservative
per-item allowance for structure. Once exhausted, remaining caller-supplied
paths are not emitted and a top-level `truncated` flag is set (using the
budget exactly with nothing omitted is *not* truncation). The response
reports `limits.max_payload_chars` / `limits.used_payload_chars`.
- The MCP `initialize` handshake validates its parameters (`protocolVersion`
non-empty string, `capabilities` object, `clientInfo` object with non-empty
`name`/`version`) and rejects malformed requests with `-32602`. A supported
requested protocol version is echoed back; an unsupported one is answered
with the server's own selected version (`2025-06-18`) rather than claimed.
This in-protocol negotiation is distinct from the HTTP
`MCP-Protocol-Version` header check, which refuses the request outright
with `400`.
- Requests over the limit return `429` with `rate-limited` and a `Retry-After`
header. The limiter is keyed by socket peer address only and never trusts
`X-Forwarded-For` or `Forwarded`. `GET /health` reports both
`configured_rate_limit_per_minute` and `effective_rate_limit_per_minute`
(in public mode an unset variable still applies the finite default).
## Browser access control
Loopback binding is not a boundary on its own: a page you merely visit can aim a
`fetch()` at `127.0.0.1`. So every request must carry a `Host` that names this
machine, and any `Origin` present must be loopback or explicitly allowed.
Requests that fail return `403` with `host-not-allowed` or `origin-not-allowed`.
| Variable | Default | Purpose |
| --- | --- | --- |
| `BRIDGE_ALLOWED_ORIGINS` | (none) | Comma-separated exact origins, e.g. `https://app.example.com` |
| `BRIDGE_ALLOWED_HOSTS` | (none) | Comma-separated hostnames, e.g. `bridge.example.com` |
`curl` and most MCP clients send no `Origin`, and those requests are accepted;
token auth still applies. If you reach the bridge by any name other than
`localhost`, `127.0.0.1`, or `::1`, add it to `BRIDGE_ALLOWED_HOSTS`.
`BRIDGE_ALLOWED_ORIGINS` is an **anti-rebinding allowlist, not CORS**. The
bridge never emits `Access-Control-Allow-Origin` and does not handle `OPTIONS`
preflight, so a browser-based cross-origin application cannot read responses
from the bridge. That is deliberate: this is a file bridge for MCP clients and
`curl`, not a public web API, and there is no wildcard CORS anywhere. To reach
the bridge from a browser extension or same-origin web app, proxy it server-side
from your own origin.
## Excluded paths
The bridge skips common secret, runtime, generated-output, dependency, and cache
paths during listing and search, and direct reads are refused for those paths.
This includes:
- `.env` and any `.env.*` variant, plus `.envrc`
- token/secret/credential-prefixed filenames
- SSH private keys (`id_rsa`, `id_ed25519`, ...) and the `.ssh` directory
- credential stores: `.netrc`, `.git-credentials`, `.npmrc`, `.pypirc`,
`kubeconfig`, `service-account.json`, and the `.aws`, `.kube`, `.docker`,
`.gnupg` directories
- key material and keystores by extension: `.pem`, `.key`, `.crt`, `.p12`,
`.pfx`, `.jks`, `.keystore`, `.kdbx`, `.p8`, `.der`, `.csr`, `.ppk`, `.asc`,
`.gpg`
- databases and backups: `.db`, `.sqlite`, `.sqlite3`, `.bak`, `.backup`
- `runtime/settings.json`, `.git`, virtualenvs, `node_modules`, common caches,
and generated output folders
Two things worth knowing about how the matching works. The prefix rules are
blunt on purpose: a file named `tokenizer.py` or `tokens.css` is hidden because
it starts with `token`. Renaming it or scoping the root more narrowly is the fix;
the bridge prefers hiding a harmless file over exposing a credential. And `.log`
files *are* readable, since tailing a log is a normal reason to use this bridge.
This list is a safety net, not a guarantee. It cannot know what a secret looks
like in your filesystem. Do not allowlist a directory if you would not be
comfortable with an authorized client reading the ordinary text files inside it.
## Exposing it beyond localhost
Don't, unless you mean it. If you put this behind a tunnel, set
`BRIDGE_PUBLIC_MODE=1` and a strong `BRIDGE_TOKEN` (>=32 chars). The bridge will
refuse to start otherwise. Even then, only the dedicated read-only endpoints are
remote-reachable; the generic `/call` dispatch stays localhost-only.
## Status
Alpha. Read-only. Expect rough edges. Issues and PRs welcome.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues