read-only-mcp
README.md
# readonly-mcp
A minimal [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
**read-only** access to a single directory tree over the stdio transport.
## Usage
```bash
npm install
npm run build
node dist/index.js /path/to/allowed/root
```
The allowed root directory is the sole command-line argument. It must exist and be a
directory, or the server exits with an error before accepting any request.
MCP client configuration:
```json
{
"mcpServers": {
"readonly": {
"command": "node",
"args": ["/absolute/path/to/readonly-mcp/dist/index.js", "/path/to/allowed/root"]
}
}
}
```
## Tools
All `path` arguments may be absolute or relative to the allowed root.
### `list_directory(path)`
Lists the entries of a directory. Each line is prefixed with `[DIR]`, `[FILE]`, `[LINK]`,
or `[OTHER]`. Errors if the path is not a directory.
### `read_file(path)`
Returns the full UTF-8 contents of a file. The file is opened with `O_RDONLY | O_NOFOLLOW`.
Directories, non-regular files (sockets, devices, FIFOs), and files larger than 10 MB are
rejected.
### `search_files(path, pattern)`
Recursively walks the tree from `path` and returns the relative paths of entries whose
**name** matches `pattern`. A pattern containing `*` or `?` is treated as a case-insensitive
glob over the entry name (`*.ts`); otherwise it is a case-insensitive substring match
(`config`). Symlinks are never followed, which prevents both traversal escapes and
directory cycles. Results are capped at 1000 and the output says so when truncated.
### `get_file_info(path)`
Returns metadata for a file or directory: relative path, absolute path, type, size in bytes,
modified/created/accessed timestamps (ISO 8601), and octal permission bits.
## Path containment
Every path argument passes through `resolveWithinRoot()` before any filesystem operation:
1. Reject empty paths and paths containing a null byte.
2. Expand a leading `~`, then resolve to an absolute path (relative paths resolve against
the allowed root). This collapses `..` segments.
3. Reject if the absolute path is not the root itself or a descendant of it.
4. `realpath()` the result to resolve every symlink in the path, including the final
component.
5. Reject again if the real path escapes the root.
Containment is a prefix check against `ALLOWED_ROOT + path.sep`, so a sibling directory
whose name merely starts with the root's name (`/data/rootabc` vs `/data/root`) does not
pass. The root itself is stored as its own `realpath()` at startup, so the comparison is
real-path against real-path.
Verified behaviors: `../outside/secret.txt`, `/etc/passwd`, `sub/../../outside/secret.txt`,
a symlinked file pointing outside the root, and a symlinked directory pointing outside the
root are all rejected with `access denied`.
## No write capability
This server cannot modify the filesystem. There is no `write_file`, `edit_file`,
`delete_file`, `move_file`, or `mkdir` tool — not as a stub, not disabled, not anywhere.
An unrecognized tool name returns `unknown tool: <name>`.
The entire implementation is one file, [`src/index.ts`](src/index.ts). The only filesystem
calls it makes are:
| Call | Purpose |
| --- | --- |
| `fs.realpath` | resolve symlinks for containment checks |
| `fs.stat` / `handle.stat` | type, size, timestamps, permissions |
| `fs.readdir` | list directory entries |
| `fs.open(p, O_RDONLY \| O_NOFOLLOW)` | open files for reading only |
| `handle.readFile` / `handle.close` | read contents, release the descriptor |
No `writeFile`, `appendFile`, `unlink`, `rm`, `rmdir`, `mkdir`, `rename`, `copyFile`,
`truncate`, `chmod`, `chown`, `utimes`, `link`, `symlink`, or `createWriteStream` appears in
the source. You can confirm this yourself:
```bash
grep -nE '\b(writeFile|appendFile|unlink|rm|rmdir|mkdir|rename|copyFile|truncate|ftruncate|chmod|chown|utimes|link|symlink|createWriteStream|write)\(|O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND' src/index.ts
```
The only hits are the four `process.stderr.write(...)` calls used for the usage message,
the two startup errors, and the startup banner. Nothing touches the filesystem. Run the
same grep over `dist/index.js` after `npm run build` to audit the compiled output.
Files are opened with the `O_RDONLY` flag at the OS call level, so even a bug elsewhere in
the process cannot write through a descriptor this server holds.
The server writes exactly one line to **stderr** at startup (the root it is serving) and
otherwise speaks only MCP over stdout.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues