MCP on NAS container
Overview
An MCP server that runs in a container on a Synology NAS and lets agents (Claude Code, Claude Desktop) read and manage files inside one NAS folder, the . Nothing outside the Root is visible. - Tools: list, stat, read (text, PDF text, images), search (name glob + optional content grep), write, edit, mkdir, move, delete. - Transport: Streamable HTTP at http:// : /mcp, LAN only (remote access via Tailscale), one static bearer token (ADR-0002). - Nothing is ever really deleted by an agent: deleted and overwritten content goes to a Trash folder inside the Root (ADR-0001). - Vocabulary (Root, Path, Trash, Hidden Entry, Destructive Operation): GLOSSARY.md. Target: DS220+ (Intel, linux/amd64), DSM 7.4.1, Container Manager. You need: DSM admin access, a Mac on the same LAN, and the NAS's LAN IP address (Control Panel > Network > Network Interface). This README uses 192.168.1.10 as a placeholder; substitute your NAS's actual IP everywhere. Don't use the router/default gateway IP (often x.x.
README
dreambig-nas-mcp
An MCP server that runs in a container on a Synology NAS and lets agents (Claude Code, Claude Desktop) read and manage files inside one NAS folder, the Root. Nothing outside the Root is visible.
- Tools:
list,stat,read(text, PDF text, images),search(name glob + optional content grep),write,edit,mkdir,move,delete. - Transport: Streamable HTTP at
http://:/mcp, LAN only (remote access via Tailscale), one static bearer token (ADR-0002). - Nothing is ever really deleted by an agent: deleted and overwritten content goes to a Trash folder inside the Root (ADR-0001).
- Vocabulary (Root, Path, Trash, Hidden Entry, Destructive Operation): GLOSSARY.md.
Target: DS220+ (Intel, linux/amd64), DSM 7.4.1, Container Manager.
Synology setup
You need: DSM admin access, a Mac on the same LAN, and the NAS’s LAN IP address (Control Panel > Network > Network Interface). This README uses 192.168.1.10 as a placeholder; substitute your NAS’s actual IP everywhere. Don’t use the router/default gateway IP (often x.x.x.1); it isn’t the NAS. Below, agents is the example shared folder used as the Root and nas-mcp the example DSM user; use your own names.
1. Choose or create the Root’s shared folder
- DSM > Control Panel > Shared Folder.
- Either pick an existing shared folder, or click Create > Create Shared Folder, name it (e.g.
agents), pick the volume (e.g. Volume 1), and finish the wizard (you can leave encryption and advanced options off). - Note its NAS path:
/volume1/agents(volume number + folder name). You can also use a sub-folder, e.g./volume1/agents/claude; the agent sees only what is mounted.
2. Create a dedicated DSM user
- Control Panel > User & Group > User > Create.
- Name:
nas-mcp. Set a long random password (nobody needs to log in as this user). Tick Disallow the user to change account password. Next. - Join groups: leave only
users. Next. - Assign shared folders permissions: tick Read/Write for
agentsonly; tick No access for every other shared folder. Next. - Assign user quota: skip. Next.
- Assign application permissions: tick Deny for every application (the container does not need DSM apps). Next.
- Set user speed limit: skip. Next, Done.
- Check: Control Panel > Shared Folder > select
agents> Edit > Permissions showsnas-mcpwith Read/Write. If you picked an existing folder, also make sure no group givesnas-mcpaccess to other folders (theusersgroup’s permissions apply to it too: check User & Group > Group > users > Edit > Permissions).
3. Find the user’s UID and GID
Option A: SSH (one-off).
- Control Panel > Terminal & SNMP > Terminal: tick Enable SSH service (port 22), Apply.
- On the Mac, in Terminal:
ssh @192.168.1.10, enter the admin password. - Run
id nas-mcp. Output looks likeuid=1030(nas-mcp) gid=100(users) groups=100(users). Write down theuidnumber (here1030) and thegidnumber (here100). exit, then untick Enable SSH service again and Apply (unless you use SSH anyway).
Option B: no SSH (Task Scheduler).
- Control Panel > Task Scheduler > Create > Scheduled Task > User-defined script.
- General tab: Task name
nas-mcp-id, Userroot, untick Enabled (you run it by hand). - Task Settings tab, Run command:
id nas-mcp > /volume1/agents/nas-mcp-id.txt(any shared folder you can open in File Station works). OK, confirm the warning. - Select the task, click Run. Open File Station, open
nas-mcp-id.txt, noteuidandgidas in option A. - Delete
nas-mcp-id.txtand thenas-mcp-idtask.
The container never runs as root: NAS_MCP_UID and NAS_MCP_GID must be these numbers, not 0.
4. Generate the bearer token
On the Mac: openssl rand -hex 32. Copy the 64-character output; it is NAS_MCP_TOKEN below and goes into each client config. Treat it like a password (anyone on the LAN who has it can read and change files in the Root). To rotate it later, change it in .env, rebuild the Project (step 7), and update the clients.
5. Image access (GHCR)
The image is ghcr.io/azthrun/dreambig-nas-mcp:latest (linux/amd64), built and pushed by CI when a GitHub release with a v* tag (e.g. v1.2.0) is published; each release is also tagged : (e.g. :v1.2.0). Prereleases get only the version tag, not :latest. It contains no secrets.
- Public package (recommended, no login on the NAS). Once, on github.com: your profile > Packages > dreambig-nas-mcp > Package settings > Danger Zone > Change visibility > Public. Check: on the Mac,
docker pull ghcr.io/azthrun/dreambig-nas-mcp:latestworks withoutdocker login(or openhttps://github.com/users/azthrun/packages/container/package/dreambig-nas-mcpin a private browser window). - Private package. Create a GitHub classic personal access token with only the
read:packagesscope (github.com > Settings > Developer settings > Personal access tokens > Tokens (classic) > Generate new token). Then over SSH on the NAS (step 3, option A):sudo docker login ghcr.io -uand paste the token as the password. Container Manager’s engine then uses those credentials forghcr.iopulls.
6. Prepare the Project folder and .env
- File Station: create a folder for the Project, e.g.
docker/dreambig-nas-mcp(create thedockershared folder first if you don’t have one). Do not put it inside the Root. - On the Mac, copy
.env.examplefrom this repo to a file namedenv.txtand fill in:NAS_MCP_TOKEN= the token from step 4NAS_MCP_UID,NAS_MCP_GID= the numbers from step 3NAS_MCP_ROOT_HOST_PATH= the Root’s NAS path from step 1, e.g./volume1/agentsNAS_MCP_HOST_PORT= a free port on the NAS (default8080; DSM itself uses 5000/5001)- Leave
NAS_MCP_ROOT,NAS_MCP_PORT,NAS_MCP_HOSTas they are (compose fixes them). Other settings: see Settings.
- In File Station, upload
env.txtintodocker/dreambig-nas-mcp, then right-click it > Rename to.env. (File Station hides dot-files unless Settings > General > Show hidden files is ticked; the file is still there.) - Keep
.envout of git and out of any folder agents can see.
7. Create the Container Manager Project
- Install Container Manager from Package Center if it isn’t installed.
- Container Manager > Project > Create.
- Project name:
dreambig-nas-mcp. Path: the folder from step 6 (/docker/dreambig-nas-mcp). Source: Upload compose.yaml / docker-compose.yml and pickcompose.yamlfrom this repo (or Create docker-compose.yml and paste its contents). Next. - Web portal settings: leave Web Station off. Next.
- Review, tick Start the project once it is created, Done. Container Manager pulls the image and starts the container. A compose error naming
NAS_MCP_UID,NAS_MCP_GIDorNAS_MCP_ROOT_HOST_PATHmeans.envis missing from the Project folder or lacks that value. - If the DSM firewall is on (Control Panel > Security > Firewall), add a rule allowing TCP
NAS_MCP_HOST_PORTfrom your LAN subnet only.
Updating later: Container Manager > Image shows Update available on ghcr.io/azthrun/dreambig-nas-mcp; click it, or Project > dreambig-nas-mcp > Action > Stop, then Action > Build to pull and recreate.
8. Check health
- Container Manager > Container: the
dreambig-nas-mcp-nas-mcp-1container shows Running and, after ~15 s, healthy (compose healthcheck on/healthzevery 30 s). - On the Mac:
curl http://192.168.1.10:8080/healthzprints{"status":"ok"}. - Auth check:
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://192.168.1.10:8080/mcpprints401(no token rejected). - If the container keeps restarting, open its log (Container > select > Details > Log). Startup errors read
Config error: ..., e.g. the token is unset or the Root is missing/unreadable (wrongNAS_MCP_ROOT_HOST_PATH, ornas-mcplacks permission on the shared folder).
9. Remote access (Tailscale, optional)
To use the server from the Mac away from home, put the NAS and the Mac on a Tailscale tailnet. No router port is opened and the server stays unchanged: same port, same bearer token (ADR-0002). Only clients that run on the Mac (Claude Code, Claude Desktop via mcp-remote) can use it; claude.ai web/mobile and Desktop’s built-in Connectors connect from Anthropic’s cloud and can’t join a tailnet. Below, `` is your NAS_MCP_HOST_PORT.
-
Tailnet. Open
https://login.tailscale.com/startand sign in with an account that has 2FA on (GitHub, Google, Apple); that account is the key to the tailnet. -
Mac. Install Tailscale from the Mac App Store, open it, menu bar icon > Log in with the same account, Allow the VPN configuration. In its settings tick Launch at login (the Mac must be on the tailnet to reach the server, at home too).
-
NAS. Open DSM by its LAN address (
http://192.168.1.10:5000), not QuickConnect: the package refuses to open over QuickConnect. Package Center > searchTailscale> Install > Open, log in with the same account > Connect. Fallback if it won’t open: SSH in (step 3, option A), runsudo tailscale up, open the printed URL on the Mac. -
Admin console (
https://login.tailscale.com/admin/machines):- NAS row > ⋯ > Edit machine name >
nas. - NAS row > ⋯ > Disable key expiry (otherwise it drops off the tailnet after ~180 days). Leave expiry on for the Mac.
- Note both
100.x.y.zaddresses (NAS and Mac). - DNS tab: MagicDNS enabled; note the Tailnet name (
.ts.net). The NAS is nownas..ts.net.
- NAS row > ⋯ > Edit machine name >
-
Access controls (console > Access controls > JSON editor). The default policy lets every device reach every port on every other device. Copy it somewhere as a backup, then replace it with the policy below (fill in the two addresses and ``) and Save. Only the Mac can then reach the NAS, only on the server’s port; DSM (5000/5001) is not reachable over the tailnet, use the LAN for DSM admin.
{ "hosts": { "macbook": "", "nas": "" }, "acls": [ { "action": "accept", "src": ["macbook"], "dst": ["nas:"] } ], "ssh": [] } -
Check from the Mac, once at home and once on another network (e.g. a phone hotspot):
curl http://nas..ts.net:/healthzprints{"status":"ok"}.curl -s -o /dev/null -w '%{http_code}\n' -X POST http://nas..ts.net:/mcpprints401.curl -m 5 http://nas..ts.net:5000times out (the policy blocks DSM).Could not resolve host: wrong tailnet name or MagicDNS off. A fastCouldn't connect: the NAS was reached but nothing listens on that port (checkis your `NAS_MCP_HOST_PORT`, in the policy too). A timeout on: the policy blocks it, or the DSM firewall does (allow TCP `` from100.64.0.0/10).
-
Clients. Use
http://nas..ts.net:/mcpas the URL in Client setup, at home and away. To switch an existing Claude Code entry:claude mcp remove nas -s user, thenclaude mcp addagain with the new URL. For Claude Desktop, replace the URL inclaude_desktop_config.jsonand restart it.
Plain http:// is fine here: tailnet traffic is end-to-end encrypted by WireGuard.
Client setup
Replace 192.168.1.10:8080 with your NAS IP and NAS_MCP_HOST_PORT (or nas..ts.net: with Tailscale), and `` with the token from step 4.
Claude Code
claude mcp add --transport http --scope user nas http://192.168.1.10:8080/mcp \
--header "Authorization: Bearer "
--scope user makes it available in every project (drop it for the current project only). Check with claude mcp list, or /mcp inside Claude Code. The token is stored in plain text in ~/.claude.json.
Claude Desktop
Desktop’s built-in Connectors connect from Anthropic’s cloud and can’t reach a LAN-only server, so use the mcp-remote bridge (a local stdio process; needs Node.js 18+ on the Mac, e.g. brew install node).
-
Claude Desktop > Settings > Developer > Edit Config (opens
~/Library/Application Support/Claude/claude_desktop_config.json). -
Add (merge into an existing
mcpServersif there is one):{ "mcpServers": { "nas": { "command": "npx", "args": [ "-y", "mcp-remote@latest", "http://192.168.1.10:8080/mcp", "--transport", "http-only", "--allow-http", "--header", "Authorization:${AUTH_HEADER}" ], "env": { "AUTH_HEADER": "Bearer " } } } }--allow-httpis required:mcp-remoterefuses plainhttp://URLs that aren’t localhost without it.Authorization:${AUTH_HEADER}(no space after:) with the space inside the env var avoids a known argument-splitting bug in some clients.- To keep the token out of the process list, use
"--header-file", "/Users//.config/nas-mcp-headers.txt"instead of--header, with that file containing the single lineAuthorization: Bearer(chmod 600it). - Claude Desktop doesn’t inherit your shell
PATH, so a barenpxfails if Node isn’t in/usr/local/binor/opt/homebrew/bin(e.g. installed via nvm). Set"command"to the absolute path fromwhich npx, and add"PATH": ":/usr/bin:/bin"to"env"(the directory containing thatnpx). Update both after upgrading Node.
-
Quit Claude Desktop completely (Cmd-Q) and reopen. The
nastools appear under the tools/connectors menu in a new chat. Logs:~/Library/Logs/Claude/mcp-server-nas.log.
Settings
All settings are env vars, read at startup; the server refuses to start with a clear Config error if one is missing or invalid (including a token that is still the placeholder or shorter than 16 characters). It also refuses to run as root (UID 0). Copy .env.example to .env (it lists every one of them). Sizes are in bytes.
| Env var | Default | Meaning |
|---|---|---|
NAS_MCP_TOKEN |
(required) | Bearer token every /mcp request must send as Authorization: Bearer . Generate with openssl rand -hex 32. At least 16 characters; the .env.example placeholder is refused. |
NAS_MCP_ROOT |
(required; compose: /data) |
The Root as seen inside the container. Fixed by compose.yaml; set only when running outside the container. |
NAS_MCP_PORT |
8080 |
Listen port inside the container (0 = pick a free port). Fixed by compose.yaml. |
NAS_MCP_HOST |
0.0.0.0 |
Listen address. Fixed by compose.yaml. |
NAS_MCP_HIDDEN_PATTERNS |
(empty) | Extra Hidden Entry globs, comma-separated. No / = entry name at any depth (*.tmp, private); with / = Root-relative Path (family/taxes). * does not match dotfiles (use .*). |
NAS_MCP_READ_TEXT_MAX_BYTES |
10485760 (10 MB) |
Max text read (whole file or one offset/limit part) and max PDF file size. |
NAS_MCP_READ_IMAGE_MAX_BYTES |
20971520 (20 MB) |
Max image file size for read. |
NAS_MCP_SEARCH_MAX_RESULTS |
100 |
Max results per search call (1-10000); the result says when it was truncated. |
NAS_MCP_GREP_MAX_FILE_BYTES |
5242880 (5 MB) |
Content search skips files larger than this. |
NAS_MCP_TRASH_RETENTION_DAYS |
30 |
Trash entries older than this many days are permanently removed; 0 = keep forever. |
NAS_MCP_TRASH_PURGE_INTERVAL_SECONDS |
3600 |
Seconds between Trash purges while running (also purged at startup). |
NAS_MCP_UID |
(required, compose only) | UID of the dedicated DSM user the container runs as (never 0). |
NAS_MCP_GID |
(required, compose only) | GID the container runs as (e.g. 100 = users). |
NAS_MCP_ROOT_HOST_PATH |
(required, compose only) | NAS folder bind-mounted as the Root, e.g. /volume1/agents. |
NAS_MCP_HOST_PORT |
8080 (compose only) |
NAS port mapped to the container’s port 8080. |
Safety model
- Root sandbox. Every Path is relative to the Root.
.., absolute paths, and symlinks resolving outside the Root are refused. The container runs as the dedicated DSM user, so DSM permissions are a second fence. - Trash instead of delete.
delete, overwritingwrite/move, andeditmove the old content to.nas-mcp-trash//at the top of the Root (name clashes get(2),(3), …). Synology’s own#recycledoes not catch deletes made from inside a container, which is why this exists. Trash entries are purged afterNAS_MCP_TRASH_RETENTION_DAYS; until then they still use disk space. The date folder is the container’s local date, which is UTC unless you setTZ. - Hidden Entries. The Trash,
@eaDir,#recycle,#snapshot,.DS_Store, and anything matchingNAS_MCP_HIDDEN_PATTERNSnever appear inlist/searchand are refused by every tool. Names and patterns match case-insensitively and after Unicode NFC normalisation (@EADIR,Family/TAXESare hidden too), and a Path is checked by where it really lands (a symlink to the Root cannot be used to write into the Trash). Agents cannot see or restore from Trash. - Crash-safe writes.
writeandeditwrite a temp file next to the target (..nas-mcp-tmp-), sync it, put the old version in the Trash as a hard link (the original stays in place), then rename the temp file over the target in one step, so a crash leaves either the old or the new version, never neither. Temp files left by a crash are removed (and audited) at the next startup. - One change at a time. All changes to the Root (write, edit, mkdir, move, delete, Trash purge) run one after another inside the server, and nothing placed in the Trash ever replaces an existing Trash entry. Changes made to the Root from outside the server (SMB, File Station) while a call runs are only partly guarded against; avoid reorganising folders while an agent is working in them.
- Client prompts.
delete,move,writeandeditcarry MCPdestructiveHint; read tools carryreadOnlyHint, so clients can ask you before Destructive Operations. - Audit log. Every write and Destructive Operation (including failed ones), every rejected request (bad token, Path escape, Hidden Entry), every Trash purge and every startup temp-file cleanup is one JSON line on stdout (
"type":"audit",time,tool,paths,result,trashed). View it in Container Manager > Container >dreambig-nas-mcp-nas-mcp-1> Details > Log (or Project >dreambig-nas-mcp> Details > Log).
Restore something from Trash
- File Station > Settings > General: tick Show hidden files, OK (the Trash folder starts with
.). - Open the Root’s shared folder (e.g.
agents) > .nas-mcp-trash > the date it was deleted or overwritten. The audit log’strashedfield gives the exact Trash Path. - Find the item under its original Path, right-click > Move to (or Copy to) and pick its original folder. If something with that name exists there now, rename one of them first.
- Optionally untick Show hidden files again.
Development
Node 24, TypeScript, @modelcontextprotocol/sdk.
npm ci
npm test # builds dist/ and runs the integration tests (MCP over HTTP against a temp Root)
npm run typecheck
NAS_MCP_TOKEN="$(openssl rand -hex 32)" NAS_MCP_ROOT=/tmp/root npm run dev
CI (.github/workflows/ci.yml) runs tests and an image smoke test on every PR, and pushes the image to GHCR only when a GitHub release with a v* tag is published.
Install
npx -y mcp-remote@latest http://192.168.1.10:8080/mcp --transport http-only --allow-http --header Authorization:${AUTH_HEADER}Configuration
{
"mcpServers": {
"nas": {
"command": "npx",
"args": [
"-y",
"mcp-remote@latest",
"http://192.168.1.10:8080/mcp",
"--transport",
"http-only",
"--allow-http",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer <token>"
}
}
}
}