Skip to main content
Glama
shunnnnn-png

synology-chat-hermes-mcp

by shunnnnn-png
README.md
# synology-chat-hermes-mcp

[English](README.md) · [繁體中文](README.zh-TW.md)

A constrained [FastMCP](https://github.com/jlowin/fastmcp) HTTP server that exposes a fixed Synology NAS SMB share to AI agents as read/write file tools — with strict path jail, size limits, and active-content rejection built in.

Originally built as the file channel for a family Hermes deployment (control agent → MCP → SMB3 → NAS). Works with any MCP client (Hermes, Claude, etc.).

## Why this exists

AI agents with file access are only safe if the access is bounded. This server:

- **Jails every path** to one fixed share (`NAS_SHARE`). Path traversal (`..`, absolute paths, symlink/reparse-point escape) is rejected before any SMB call.
- **Rejects public WAN servers** — `NAS_SERVER` must be a private, link-local, or Tailscale (100.64.0.0/10) literal IP. DNS names and public IPs are refused.
- **Never stores credentials** — the SMB password is read from a docker secret file at startup (`NAS_PASSWORD_FILE`), never from env vars or code.
- **Blocks active content** — DOCX/XLSX/PDF writers generate sanitized documents only; binary uploads are scanned for macros, external relationships, and scripts; extension/media-type mismatches are rejected.
- **Bounds everything** — file size, binary size, document characters, spreadsheet cells, image pixels, search results all have hard limits.
- **Audits operations** — every mutation is appended to a JSONL audit log.

## Architecture

```
Hermes (or any MCP client)
  → HTTP :8000/mcp (FastMCP, streamable HTTP)
    → NasService (validation, limits, audit)
      → SMBBackend (smbprotocol, SMB3, encryption+signing required)
        → Synology NAS share
```

## Tools

| Tool | Description |
|---|---|
| `health` | SMB3 connectivity check |
| `list_directory` | List a directory below the share |
| `search_files` | Search file names below a path |
| `read_file` | Read a size-limited UTF-8 text file |
| `write_file` | Atomic UTF-8 write (existing files protected unless `overwrite`) |
| `write_binary_file` | Validated base64 write (TXT/DOCX/XLSX/PDF/PNG/JPEG) |
| `create_document` | Safe DOCX from title + plain/Markdown-like text |
| `create_spreadsheet` | Safe XLSX (formulas rejected) |
| `create_pdf` | Static PDF (no scripts/links/embedded files) |
| `read_document` | Extract text from TXT/MD/CSV/JSON/YAML/DOCX/XLSX/PDF |
| `create_directory` | Create a directory (note: create one level at a time — `parents=True` is unreliable, see below) |
| `move_file` | Move within the share |
| `get_file_info` | Metadata without content |

**Known limitation:** `create_directory` validates the parent before `makedirs`, so a multi-level path whose first segment doesn't exist returns `not_found` even with `parents=True`. Create one level at a time.

## Quick start

```bash
cp .env.example .env   # set NAS_SERVER (Tailscale/private IP), NAS_USERNAME
# provide the SMB password as a docker secret:
printf '%s' 'YOUR_SMB_PASSWORD' > ./synology_password
chmod 600 ./synology_password

docker build -t nas-mcp .
docker run -d --name nas-mcp \
  --env-file .env \
  -v "$PWD/synology_password:/run/secrets/synology_password:ro" \
  -v nas-mcp-audit:/var/log/nas-mcp \
  -p 8000:8000 \
  nas-mcp
```

Health check:

```bash
curl -X POST http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"health","arguments":{}}}'
```

### Hermes MCP config

```yaml
mcp_servers:
  nas-mcp:
    type: http
    url: http://nas-mcp:8000/mcp
```

### Running tests

```bash
python -m nas_mcp.smoke   # exercises the full tool chain against the live share
```

## Environment reference

| Variable | Required | Default | Notes |
|---|---|---|---|
| `NAS_SERVER` | ✅ | — | Private/Tailscale literal IP only |
| `NAS_SHARE` | ✅ | — | Fixed share name, enforced at runtime |
| `NAS_USERNAME` | ✅ | — | Share-level SMB user |
| `NAS_PASSWORD_FILE` | | `/run/secrets/synology_password` | Docker secret path |
| `NAS_CONNECT_TIMEOUT_SECONDS` | | `8` | |
| `NAS_REQUIRE_ENCRYPTION` | | `true` | SMB3 encryption required |
| `NAS_REQUIRE_SIGNING` | | `true` | SMB3 signing required |
| `NAS_AUDIT_LOG` | | `/var/log/nas-mcp/audit.jsonl` | |
| `NAS_MAX_FILE_BYTES` | | `1048576` | |
| `NAS_MAX_BINARY_FILE_BYTES` | | `8388608` | |
| `NAS_MAX_DOCUMENT_CHARS` | | `100000` | |
| `NAS_MAX_SPREADSHEET_CELLS` | | `50000` | |
| `NAS_MAX_DOCUMENT_EXPANDED_BYTES` | | `67108864` | |
| `NAS_MAX_IMAGE_PIXELS` | | `25000000` | |
| `NAS_MAX_SEARCH_ENTRIES` | | `5000` | |
| `NAS_MAX_SEARCH_RESULTS` | | `100` | |
| `NAS_MCP_HOST` / `NAS_MCP_PORT` | | `0.0.0.0` / `8000` | |

## Security notes

- The SMB password is the only secret in the system and it lives **only** in the docker secret file. Nothing is hardcoded, nothing is stored in `.env` in plaintext beyond non-secret tuning values (and `.env` is git-ignored).
- Bind to a private interface in production; do not expose `:8000` publicly.
- The share name and SMB user are configuration (`NAS_SHARE`, `NAS_USERNAME`) — set them per deployment; nothing in the code assumes a particular value.

## License

MIT