Skip to main content
Glama
wva2ccyk-prog

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.