Skip to main content
Glama
README.md
# Librarian MCP

An [MCP](https://modelcontextprotocol.io) server that lets an AI client browse, search and read files you mount into its Docker container, reached remotely through a tunnel.

Mount a folder on `/data`, start the container, and it serves that folder over MCP (Streamable HTTP with a bearer token). The image bundles [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) and opens the tunnel itself, so a single `docker run` gives you a public HTTPS endpoint.

## Tools

| Tool | What it does |
| --- | --- |
| `list_directory` | Entries of a directory with type, size, mode, owner and mtime |
| `stat` | Metadata for one path (symlinks are reported, not followed) |
| `read_file` | Read a file or a byte range, as UTF-8 or base64, paged by `MAX_READ_BYTES` |
| `search_files` | Find names matching a glob (`*.log`, `nginx*.conf`) under a directory |
| `write_file`, `make_directory`, `move`, `delete` | Only registered when `WRITE_ENABLED=true` |

Clients see the mounted folder as `/`, so `/reports/q3.csv` is `/data/reports/q3.csv` in the container. Paths are resolved like a chroot: `..` stops at that root, and symlinks, absolute or relative, are followed inside the mounted folder, never outside it.

## Quick start

```sh
docker build -t librarian-mcp .

# Serve a host folder, read-only
docker run -d --name librarian -v /path/to/files:/data:ro librarian-mcp
docker logs -f librarian
```

The logs print everything a client needs:

```
Librarian MCP is reachable at:
  URL:   https://<random-words>.trycloudflare.com/mcp
  Token: 3f1c…  (generated, set MCP_TOKEN to choose your own)

  Claude Code:
  claude mcp add --transport http librarian https://<random-words>.trycloudflare.com/mcp --header "Authorization: Bearer 3f1c…"

  Claude Desktop / claude.ai:
  Settings > Connectors > Add custom connector, URL https://<random-words>.trycloudflare.com/mcp
  then paste the token on the authorization page that opens.
```

Mount with `:ro` unless you also set `WRITE_ENABLED=true`. A named Docker volume works the same way (`-v my-volume:/data`).

The same setup as a compose file is in [`examples/docker-compose.yml`](examples/docker-compose.yml).

### Tunnel modes

- **Quick tunnel** (default): no Cloudflare account needed. The URL is random and changes on every restart, and Cloudflare gives no uptime guarantee, so it is meant for ad hoc access.
- **Named tunnel**: create a tunnel in the Cloudflare dashboard (Zero Trust → Networks → Tunnels), add a public hostname pointing to `http://localhost:8787`, and pass its token as `CLOUDFLARE_TUNNEL_TOKEN`. The endpoint is then `https://<your-hostname>/mcp`, stable across restarts, and you can put Cloudflare Access in front of it.
- **No tunnel**: `TUNNEL=none` serves on `0.0.0.0:8787` only, for use with `-p` or your own tunnel (ngrok, Tailscale, `ssh -R`).

When a tunnel is on, the server listens on `127.0.0.1` only, so the tunnel is the only way in. If cloudflared stops, the container exits so its restart policy can bring both back.

Without Docker, `npm run build && ROOT_DIR=/path/to/files MCP_TOKEN=… node dist/index.js` serves a local folder (add `--stdio` for a local stdio client).

### Client configuration

There are two ways to authenticate, and both use the same `MCP_TOKEN`:

- **Bearer token**, for clients that let you set a header (Claude Code, most MCP configs).
- **OAuth**, for Claude Desktop and claude.ai custom connectors, which only support OAuth. Add a custom connector with the `/mcp` URL and no client ID or secret. Claude identifies itself with its published client ID (a Client ID Metadata Document URL, which the server fetches and checks) or registers itself dynamically, then opens an authorization page served by Librarian MCP, and you paste `MCP_TOKEN` there once. Claude then gets its own access token (valid one hour) and refresh token (valid 30 days).

OAuth needs the server's public URL. It is detected automatically with a quick tunnel; with a named tunnel or your own tunnel, set `PUBLIC_URL=https://<your-hostname>`. Client registrations and tokens are encrypted with a key derived from `MCP_TOKEN` rather than stored, so they survive restarts, and changing `MCP_TOKEN` revokes all of them. With a quick tunnel the URL changes on every restart, so the connector has to be added again. The authorization page shows the host a published client ID comes from (for example `claude.ai`), since the client name itself is self-declared.

```json
{
  "mcpServers": {
    "librarian": {
      "type": "http",
      "url": "https://<your-tunnel>/mcp",
      "headers": { "Authorization": "Bearer <MCP_TOKEN>" }
    }
  }
}
```

With Claude Code: `claude mcp add --transport http librarian https://<your-tunnel>/mcp --header "Authorization: Bearer <MCP_TOKEN>"`.

## Configuration

| Variable | Default | Meaning |
| --- | --- | --- |
| `ROOT_DIR` | `/data` in the image, `/` otherwise | Directory exposed to clients as `/` |
| `TUNNEL` | `cloudflare` in the image, `none` otherwise | `cloudflare` starts the bundled cloudflared, `none` disables it |
| `CLOUDFLARE_TUNNEL_TOKEN` | unset | Named tunnel token. Unset means a quick tunnel |
| `MCP_TOKEN` | generated with a tunnel, else required | Bearer token for `/mcp`. Without a tunnel the server refuses to start without it |
| `MCP_ALLOW_NO_AUTH` | `false` | Run without a token, only on a trusted network. Also disables OAuth |
| `PUBLIC_URL` | detected with a quick tunnel | Public base URL (`https://…`), required for OAuth with a named tunnel or no tunnel |
| `WRITE_ENABLED` | `false` | Register the write tools |
| `MAX_READ_BYTES` | `1048576` | Largest chunk `read_file` returns per call |
| `HOST` / `PORT` | `127.0.0.1` with a tunnel, else `0.0.0.0` / `8787` | HTTP listen address |
| `CLOUDFLARED_PATH` | `cloudflared` | cloudflared binary to run |
| `MCP_TRANSPORT` | `http` | `stdio` to serve over stdin/stdout (same as `--stdio`) |

`GET /healthz` answers without auth, for tunnel and container health checks.

## Security

A quick tunnel makes the server reachable from the whole internet, protected only by the token (directly, or through the OAuth authorization page, which locks for a minute after 5 wrong tokens), and the URL shows up in your container logs. Anyone holding the token can read every file in the mounted folder. Keep the server read-only unless you need writes, use a long random token, and prefer a tunnel that adds its own access control (Cloudflare Access, Tailscale). To read client ID metadata documents the server makes outbound HTTPS requests to the URL a client presents; it refuses non-public addresses, redirects, documents over 5 KB and slow hosts, and caches results for five minutes. Symlinks are re-resolved on each call, but something that swaps a path component for a symlink between resolution and use can race the check, so do not mount a folder that untrusted processes write to while it is being served.

## Development

```sh
npm install
npm test          # vitest
npm run typecheck
npm run dev       # tsx src/index.ts, needs MCP_TOKEN or MCP_ALLOW_NO_AUTH=true
```

## Releases

Every push to `main` runs [semantic-release](https://semantic-release.gitbook.io/) once CI is green. It reads the commits since the last tag, so commit messages must follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix:` makes a patch, `feat:` a minor, `feat!:` or a `BREAKING CHANGE:` footer a major; `chore:`, `docs:`, `ci:` and the like release nothing). Pull requests run commitlint to catch bad messages.

A release creates the `vX.Y.Z` tag and GitHub release, commits `CHANGELOG.md`, and pushes a multi-arch image (amd64, arm64) to the GitHub Container Registry, so you can skip the build:

```sh
docker run -d --name librarian -v /path/to/files:/data:ro ghcr.io/pierrickrouxel/librarian-mcp:latest
```

Tags: `X.Y.Z`, `X.Y`, `X` and `latest`. Nothing is published to npm, and `package.json` carries no version: the tag is the source of truth, and the image passes it to the server through `LIBRARIAN_VERSION` (local builds report `0.0.0-dev`).