librechat-personal-files-mcp
by martinriesel
README.md
# librechat-personal-files-mcp
MCP server providing per-user personal file storage, persistent documentation, RAG indexing/retrieval, and opaque public link publishing for LibreChat agents.
> **Related fork**: this server is designed to work with a fork of
> [ForLegalAI/mcp-ms-office-documents](https://github.com/ForLegalAI/mcp-ms-office-documents)
> that stores generated Office documents (`.docx`/`.xlsx`/`.pptx`/`.eml`/`.xml`)
> in the user's private storage here via the `POST /binary` endpoint. See the
> fork's documentation for the `PERSONAL_FILES` upload strategy.
## Features
- **Per-user storage**: Private files under `/data/private/<userId>/` with full CRUD, list, move operations.
- **Binary file support**: `write_binary_file` / `read_binary_file` (base64) MCP tools plus a `POST /binary` multipart endpoint (service-token authenticated) so other services — e.g. the Office-documents fork — can store generated `.docx`/`.xlsx`/`.pptx` files per user.
- **Documentation catalog**: `save_documentation` / `update_documentation` write to `docs/` and update `memory-index.json` (schema v2).
- **RAG integration**: `index_document` sends content to rag_api via `POST /embed` with canary verification; `search_knowledge` for semantic search; `remove_from_knowledge` for deletion.
- **Public publishing**: `publish_file` creates cryptographically random tokens (≥128 bits); files served via Nginx `X-Accel-Redirect` with `Content-Disposition: attachment`, `nosniff`, `no-store`.
- **Strict security**: Fail-closed on missing/invalid `X-User-Id`; path traversal blocked; no absolute paths; no `..` segments; symlink escape detection; reserved segment protection; atomic index writes with cross-process locking.
## Architecture
```
┌──────────────┐ ┌────────────────────────────────┐ ┌─────────────┐
│ LibreChat │────▶│ librechat-personal-files-mcp │──▶ │ rag_api │
│ (Agent) │ MCP │ (stateless HTTP /mcp) │ │ (vector DB) │
└──────────────┘ └────────────────────────────────┘ └─────────────┘
│
│ GET /files/{token}
▼
┌──────────────┐
│ Nginx │
│ (X-Accel) │
└──────────────┘
│
▼
┌──────────────┐
│ /data/private│ (read-only bind)
└──────────────┘
```
## Requirements
- A running [LibreChat](https://www.librechat.ai/) instance with `rag_api` enabled.
- A `JWT_SECRET` shared with LibreChat and `rag_api` (see below).
- Nginx as the reverse proxy in front of LibreChat (required for public links via `X-Accel-Redirect`).
## Installation
### 1. Get the Docker image
Pull the pre-built image from GHCR:
```bash
docker pull ghcr.io/martinriesel/librechat-personal-files-mcp:latest
```
Or build it yourself:
```bash
git clone https://github.com/martinriesel/librechat-personal-files-mcp.git
cd librechat-personal-files-mcp
docker build -t librechat-personal-files-mcp .
```
### 2. Prepare the host (run once, as root)
Create the data directory and a shared group. The container runs as UID `2000` / GID `2500`; Nginx (which serves published files) needs read access.
```bash
cd /path/to/your/librechat
groupadd -g 2500 lcfiles 2>/dev/null || true
mkdir -p ./data/private/_system
chown -R 2000:2500 ./data/private
chmod 2771 ./data/private # group rwx + setgid; world executable so nginx can traverse
setfacl -R -m g:2500:rX,d:g:2500:rX ./data/private 2>/dev/null || \
echo "ACL tools absent; using 644/755 fallback"
```
Ensure `JWT_SECRET` is set in your LibreChat `.env` (same value must be shared with `rag_api`):
```bash
grep -q '^JWT_SECRET=' .env || echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
```
### 3. Add the service to your compose file
Add the blocks from `docker-compose.snippet.yml` to your `docker-compose.override.yml`:
- A `librechat-personal-files` service (image, environment, volumes, networks).
- An extra read-only volume `./data/private:/data/private:ro` on the `nginx` service.
Set `PUBLIC_BASE_URL` to your own public URL, e.g. `https://chat.example.com/files`.
Then start it:
```bash
docker compose up -d librechat-personal-files nginx
```
### 4. Configure the Nginx reverse proxy
Add these blocks to your existing Nginx server block (the same file LibreChat uses):
```nginx
limit_req_zone $binary_remote_addr zone=pubfiles:10m rate=5r/s;
location ^~ /files/ {
limit_req zone=pubfiles burst=10 nodelay;
limit_req_status 429;
proxy_pass http://librechat-personal-files:8080/files/;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
}
location ^~ /_protected/ {
internal;
alias /data/private/;
}
```
Reload Nginx:
```bash
docker exec <nginx-container> nginx -s reload
```
### 5. Activate the MCP server in LibreChat
In the LibreChat **Admin Panel → MCP Servers**, add (or in your `librechat.yaml`):
```yaml
mcpSettings:
allowedAddresses:
- 'librechat-personal-files:8080'
mcpServers:
personal-files:
type: streamable-http
url: http://librechat-personal-files:8080/mcp
timeout: 120000
chatMenu: false
headers:
X-User-ID: '{{LIBRECHAT_USER_ID}}'
X-User-Email: '{{LIBRECHAT_USER_EMAIL}}'
```
Restart LibreChat. The `personal-files` server will be available to your agents.
### 6. Service-to-service binary uploads (`POST /binary`)
Other services can store files in a user's private storage without speaking
MCP. Set `SERVICE_TOKEN` on this server and have the service send a multipart
request:
```
POST http://librechat-personal-files:8080/binary
X-Service-Token: <SERVICE_TOKEN>
X-User-Id: <userId>
form: file=<bytes>, path=office/report.docx
```
Both `SERVICE_TOKEN` and a valid `X-User-Id` are required (fail-closed); the
path goes through the same path-safety checks as every other tool. On success
it returns `{path, filename, mime_type, size_bytes, sha256}`.
This is how the [Office-documents fork](https://github.com/ForLegalAI/mcp-ms-office-documents)
stores generated `.docx`/`.xlsx`/`.pptx` files per user (see its `PERSONAL_FILES`
strategy).
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `USER_HEADER` | `X-User-Id` | Header carrying the user identity |
| `STORAGE_ROOT` | `/data/private` | Root directory for user data |
| `SHARE_ROOT` | `/data/share` | Legacy shared area (read/write during migration) |
| `RAG_API_URL` | `http://rag_api:8000` | rag_api endpoint |
| `JWT_SECRET` | *required* | HS256 secret shared with LibreChat/rag_api (≥32 chars) |
| `PUBLIC_BASE_URL` | `https://your-domain.example/files` | Base URL for public links |
| `MAX_FILE_SIZE_MB` | `20` | Max upload size for text files |
| `MAX_BINARY_FILE_MB` | `50` | Max upload size for binary files (`/binary`, `write_binary_file`) |
| `SERVICE_TOKEN` | *(empty)* | Shared token required by the `POST /binary` endpoint; without it the endpoint returns 503 |
| `REGISTRY_DB` | `/data/private/_system/links.db` | SQLite registry for public links |
## MCP Tools
### Storage
- `list_files(path="", recursive=false, pattern=null)` — list files/dirs
- `read_file(path)` — read UTF-8 text; error for binary or >2 MB
- `read_multiple_files(paths)` — read several text files at once
- `write_file(path, content)` — write text (UTF-8), creates parent dirs
- `write_binary_file(path, content_base64)` — write a binary file (e.g. Office documents); uses the binary size limit, never indexes
- `read_binary_file(path)` — read any file as base64 (binary or text)
- `update_file(path, content)` — update existing file
- `edit_file(path, edits)` — find/replace text (each `oldText` must match exactly once)
- `delete_file(path)` — delete file or directory
- `move_file(src, dst)` — move within user root
- `get_file_info(path)` — metadata + docindex + publishing status
### Documentation
- `save_documentation(filename, content, title?, description?, tags?, topics?)` — saves to `docs/`, updates index, **does not** publish or index
- `update_documentation(filename, content, ...)` — update existing doc
- `get_document_metadata(filename)` — full index entry
### RAG
- `search_knowledge(query, limit=8)` — semantic search (owner-scoped via JWT)
- `index_document(path)` — embed + canary verify, updates index status
- `remove_from_knowledge(path)` — delete from rag_api, clears index
- `get_index_status()` — counts + rag_api health
### Publishing
- `publish_file(path, expires_in_days?)` — create/reuse public link, returns token + URL
- `unpublish_file(path_or_token)` — revoke link (file stays private)
- `get_public_link(path)` — active link for path
- `list_public_links()` — all links for user
## Security Model
- **Identity**: `X-User-Id` header injected by LibreChat (`{{LIBRECHAT_USER_ID}}`). Placeholder unresolved → empty string → **fail-closed**.
- **Fail-closed**: Missing/empty/invalid header → HTTP 403 `{"error":"missing_user_identity"}` or `{"error":"invalid_user_identity"}`.
- **Path safety**: All paths are relative; absolute paths and `..` rejected; symlink escape detected via `Path.resolve()` + prefix check.
- **Isolation**: rag_api owner scope enforced by JWT `sub`/`id` = userId.
- **Public links**: Opaque `secrets.token_urlsafe(16)` tokens; no user/path in URL; revocation via `410 Gone`; lazy cleanup of expired entries.
## Development
```bash
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
# Run tests
.venv/bin/pytest -q
# Lint
.venv/bin/ruff check .
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues