Policy enforcement, OS sandboxing, and JSON-RPC auditing for MCP servers
概要
Policy enforcement, OS sandboxing, and JSON-RPC auditing for local stdio Model Context Protocol (MCP) servers. mcp-writ sits between the MCP client and server, enforcing fine-grained security policies — filesystem access control, syscall filtering, and tool allowlisting. Native syscall analysis covers Linux x86-64 and AArch64 ELF binaries and macOS ARM64 Mach-O binaries — see Supported targets. mcp-writ is a control-plane enforcement point: it verifies the server's tool definitions when a tools-list-hash pin is configured, permits or denies tools/call, constrains path and host arguments, controls the launch environment, and records an audit log. Data-plane inspection — response-body DLP, HTTP/SSE gateways, LLM-based judgment — is a different layer's job; deploy mcp-writ in series with such inspectors rather than expecting it to replace them. - — OS sandboxing on Linux, Windows, and macOS, combined with JSON-RPC auditing. - — runs without root privileges.
README
mcp-writ
Policy enforcement, OS sandboxing, and JSON-RPC auditing for local stdio Model Context Protocol (MCP) servers. mcp-writ sits between the MCP client and server, enforcing fine-grained security policies — filesystem access control, syscall filtering, and tool allowlisting. Native syscall analysis covers Linux x86-64 and AArch64 ELF binaries and macOS ARM64 Mach-O binaries — see Supported targets.
mcp-writ is a control-plane enforcement point: it verifies the server’s
tool definitions when a tools-list-hash pin is configured, permits or
denies tools/call, constrains path and host arguments, controls the
launch environment, and records an audit log.
Data-plane inspection — response-body DLP, HTTP/SSE gateways, LLM-based
judgment — is a different layer’s job; deploy mcp-writ in series with such
inspectors rather than expecting it to replace them.
Features
- Multi-layer defense — OS sandboxing on Linux, Windows, and macOS, combined with JSON-RPC auditing.
- Non-privileged operation — runs without root privileges.
- Static analysis — inspect native ELF and Mach-O binaries and supported scripts — Python and JavaScript/TypeScript sources; other script files are identified from their shebang or the command name — to surface capabilities before execution.
- Policy generation and testing — generate KDL policy drafts, with optional tool discovery, self-tests, and dry-run auditing to help review them.
- Container support — build and wrap MCP server images, then run them with policy enforcement using Docker or Podman.
- Tool access controls — check tool permissions and arguments, hide denied or unlisted tools from
tools/list, protect sensitive paths, and optionally restrict sequences of tool calls. - Tool definition verification — scan advertised tool definitions, verify pinned hashes, and revalidate changes before resuming tool calls.
See the detailed guide for configuration, enforcement behavior, and platform limitations.
Architecture
flowchart LR
C[MCP Client] --> A[Auditor: JSON-RPC checks]
A --> S[MCP Server]
P[Policy] --> A
P --> W[Warden: OS sandbox]
W -.-> S
I[Inspector: static analysis] --> L[Legislator: policy draft]
L --> P
Inspector analyzes binaries and supported source files. Legislator uses those results and optional live tool discovery to draft a policy. At runtime, Warden applies the OS sandbox and Auditor checks MCP traffic. See the module guide for module responsibilities.
Supported MCP versions
mcp-writ supports stdio MCP 2026-07-28 and 2025-11-25 in the same build.
Other revisions are not assumed compatible. HTTP/SSE transport is out of scope
by design — stdio is the implemented runtime.
See the protocol reference
for discovery, retries, and inputResponses handling.
Supported targets
The CLI itself builds and runs on Windows, Linux, and macOS on x86-64 and ARM64; the release workflow builds archives for all six combinations. Sandbox enforcement is OS-specific — see Security boundaries.
inspect and generate-policy analyze the input binary’s format, ISA, and
ABI independently of the host the CLI runs on:
| Input | Result |
|---|---|
| ELF64 little-endian, x86-64 or AArch64, Linux ABI | Syscall sites decoded and resolved (syscall/rax on x86-64, svc/x8 on AArch64) |
Mach-O thin or fat (universal), plain arm64 slice, Darwin |
svc #0x80/x16 resolved against XNU BSD syscall and Mach trap tables; other slices keep their own unsupported state |
Interpreter payloads (python, node, scripts, shebang) |
Source/AST capability analysis instead of native decoding |
| Other formats, ISAs, ABIs, or slices | Reported with unsupported / partial / failed analysis state — never presented as “no syscalls” |
Which execution method (native, container, or a VM backend) exists for a given host — and whether it is adopted, conditional, a candidate, or refused — is listed in the support matrix.
Quick Start
From a source checkout with Rust and
Node.js installed, using the pinned
@modelcontextprotocol/server-filesystem 2026.8.31 server. Replace
/srv/mcp-data with the directory the server may reach:
cargo install --locked --path . --bin mcp-writ
npm install -g @modelcontextprotocol/[email protected]
# In the checkout, extend the pinned example, add this host's allowances,
# and open one tool on your data root. Node/npm paths come from the host
# itself; /srv/mcp-data stays a placeholder for your own data root.
# `command -v node` often resolves to a shim or symlink (nvm, Homebrew,
# version managers) — the sandbox needs the real binary's directory, so
# resolve the link chain first.
node_bin="$(command -v node)"
while [ -L "$node_bin" ]; do
link="$(readlink "$node_bin")"
case "$link" in
/*) node_bin="$link" ;;
*) node_bin="$(dirname "$node_bin")/$link" ;;
esac
done
node_dir="$(dirname "$node_bin")"
node_prefix="$(dirname "$node_dir")"
npm_root="$(npm root -g)"
cat > policy.kdl <<EOF
policy version=1
extends "examples/policies/filesystem.kdl"
defaults {
filesystem {
// Host-specific read grants resolved above — the node binary's
// directory, its install prefix, and the global npm package tree.
// Sandboxed launch needs the remaining host paths from the
// quickstart walkthrough
allow "$node_dir" mode="read"
allow "$node_prefix" mode="read"
allow "$npm_root" mode="read"
allow "/srv/mcp-data" mode="read"
}
// Restrict the child's environment to the launch contract's base
// set (PATH, temp dirs, Windows system vars). Without this node the
// server inherits your entire shell environment — ambient
// credentials (cloud keys, tokens) included — which a dry-run
// server could exfiltrate. Add `allow "NAME"` entries only for
// variables this server demonstrably needs.
environment {
}
}
server "filesystem" {
tool "read_file" { filesystem { allow "/srv/mcp-data/**" } }
// Only read_file is permitted — the inherited tools are denied
// explicitly; adding read_file alone does not revoke them.
tool "read_text_file" deny=#true
tool "read_multiple_files" deny=#true
tool "read_media_file" deny=#true
tool "list_directory" deny=#true
tool "list_directory_with_sizes" deny=#true
tool "directory_tree" deny=#true
tool "search_files" deny=#true
tool "get_file_info" deny=#true
tool "list_allowed_directories" deny=#true
tool "write_file" deny=#true
tool "edit_file" deny=#true
tool "create_directory" deny=#true
tool "move_file" deny=#true
}
EOF
mcp-writ run --dry-run --policy policy.kdl --audit-log ./audit.jsonl -- mcp-server-filesystem /srv/mcp-data
Point an MCP client (or a JSON-RPC script) at that run command: read_file
under /srv/mcp-data goes through. This example passes --dry-run, which
keeps the OS sandbox off — including the network deny — and forwards calls
taking other paths while logging them as violations. The environment
block strips inherited variables but is not a credential boundary: an
unsandboxed server can read any file the running user can — SSH keys,
cloud credentials, browser sessions — and send them anywhere. Use test
data, and run dry-run only inside an isolated environment containing no
credentials. Without --dry-run, normal execution denies calls outside
the allowance. For tool discovery, host defaults, the sandboxed check
(scripts/check-server.sh / .ps1), and the Windows launch form, see the
quickstart walkthrough; tailor the policy further with
the policy authoring guide.
Client configuration
Point your MCP client at mcp-writ run instead of launching the server
directly. Claude Desktop (claude_desktop_config.json) and Cursor
(.cursor/mcp.json) use the mcpServers..{command,args,env} shape:
{
"mcpServers": {
"filesystem": {
"command": "mcp-writ",
"args": [
"run",
"--policy", "/opt/mcp-config/policy.kdl",
"--audit-log", "/opt/mcp-logs/audit.jsonl",
"--", "mcp-server-filesystem", "/srv/mcp-data"
],
"env": {}
}
}
}
VS Code’s .vscode/mcp.json uses the same three fields under a top-level
servers key instead of mcpServers, plus a required "type": "stdio"
on each server entry. Values set in env are passed to the
spawned server’s environment as-is unless the policy declares
defaults.environment — with an allowlist present, a variable reaches the
child only when it is listed there (or is one of the baseline PATH /
system variables). TMPDIR, TMP, and TEMP are injected automatically
only on launch paths that assign a dedicated private temporary directory;
on other launch paths, list them in the allowlist to pass the parent’s
values. If the client cannot find mcp-writ on
PATH, put the absolute executable path in command.
Subcommands
| Command | Description |
|---|---|
run |
Run an MCP server with security policies applied |
capability-matrix |
Print the machine-readable per-OS capability matrix (JSON) — mechanism coverage, requirements, deficiencies |
inspect |
Analyze a native ELF or Mach-O binary, or an interpreter payload via source/AST (--format human|json|kdl) |
generate-policy |
Generate a policy KDL from binary or source analysis (static-only by default; --live-discovery and --self-test are opt-in) |
run-image |
Run a secured container image with policy and log mounts |
wrap-image |
Wrap an existing image with mcp-secure-runner (policy baked in) |
containerize |
Build a secured image from an MCP server source directory |
Examples
# Inspect a native binary, or a script (native analysis is skipped for interpreters)
mcp-writ inspect ./my-mcp-server --format json
mcp-writ inspect --format json server.py
# Log and forward tool-call violations without OS sandboxing (server
# execution may have side effects; blocking tool-definition checks still apply)
mcp-writ run --dry-run --policy policy.kdl --audit-log ./audit.jsonl -- ./my-mcp-server
# Run with audit log file
mcp-writ run --policy policy.kdl --audit-log /var/log/mcp-audit.jsonl -- ./my-mcp-server
# Wrap then run a container image
mcp-writ wrap-image --policy policy.kdl my-mcp-server:latest
# For this local build, explicitly allow the mutable tag
mcp-writ run-image --allow-mutable-tag --engine docker --policy policy.kdl --log-dir ./logs my-mcp-server-secured:latest
Policy File
Policies are defined in KDL. This Linux-oriented example illustrates the syntax; adapt paths, syscalls, and tools to your server:
policy version=1
defaults {
filesystem {
allow "/usr/lib/**" mode="read"
allow "/etc/ssl/certs/**" mode="read"
allow "/workspace/**" mode="write"
}
syscalls {
allow "read" "write" "openat" "close" "fstat" "mmap" "brk" "execve" "exit_group"
}
}
server "my-mcp-server" {
// input_responses="auto" denies separate inputs on constrained tools.
tool "read_file" side_effect="read_only" {
filesystem {
allow "/workspace/**"
deny "/home/*/.ssh/**"
}
}
tool "exec_shell" deny=#true
}
See policy.example.kdl for a complete example (including secret-overlay, side_effect, opt-in trajectory, and the MRTR input_responses note). On Windows, a host allowlist combined with deny host="*" is rejected at policy load; see Platform notes (Windows).
Security boundaries
Protection depends on the policy and platform — the
per-OS enforcement matrix maps each
policy area to its per-OS behavior, and
mcp-writ capability-matrix
emits the same claims as machine-readable JSON
(docs/capability-matrix.json, kept in sync
with the implementation by test).
The native warden is Linux-first by design — Landlock + seccomp is the
mechanism whose guarantees are most completely verifiable. On other OSes
the native mechanism is what the platform offers; when a workload needs a
stronger boundary, the design’s answer is an isolation route
(--isolation kata|apple-container|hyperv|windows-sandbox, or an OCI
container), never a silent substitute.
What the guard enforces
- Linux (the primary target): Landlock filesystem confinement plus a
seccomp allowlist and
no_new_privs; allowed tools’filesystemrules merge into one process-wide ruleset. - Windows: AppContainer, Job Object, and DACL grants; OS network control
is deny-all or unrestricted — no per-destination OS filtering. An opt-in
--windows-mechanism psec(conditional, x86-64 only) replaces the AppContainer layer with a PSEC security environment that does enforce per-destination IPv4 egress — see the platform notes. - macOS:
sandbox-exec(legacy SBPL) enforces the globalfilesystemlists; per-toolfilesystem/networkis Auditor-only anddefaults.syscallsis not applied. - Everywhere: in a normal run, the tool allowlist,
args_schema,side_effect, and the secret-path overlay are checked ontools/callarguments; violations return a JSON-RPC error.tools-list-hashis compared against thetools/listresponse — on a mismatch the guard returns an error response and aborts the session. Under--dry-runtools/callviolations are forwarded instead (loggedobserved), and a client-initiatedtools/listpin mismatch is also forwarded rather than aborting. Adefaults.environmentallowlist restricts the child process’s environment variables at launch — including--dry-runandMCP_WRIT_SKIP_SANDBOXruns — and the parent environment is inherited unchanged when the node is absent. - Before spawn:
binary-hash/entrypoint-hashpins on the launch target are verified, bound to the resolved executable / first payload argument, and re-verified immediately beforeexec— the executable and a matched entrypoint script are both re-hashed there, so a script swapped in after the first check fails closed instead of passing on path correspondence alone. With such pins declared, a hash mismatch or an inline-eval launch fails closed. Payloads that cannot be bound from argv (python -m,npx) are not pinned;generate-policyrecords the gap as a// REVIEW:comment instead of fabricating a hash.
What it does not guarantee
- Per-tool
filesystem/networkrules inspect RPC arguments; they are not a per-tool OS sandbox and do not cover server-internal access. --dry-runruns the server without the OS sandbox; violations are logged and forwarded — execution can have real side effects.- TOCTOU between argument check and use is outside the Auditor’s scope; only the OS layer’s own coverage closes it.
confused_deputy_protectionis opt-in — a per-processknown_pathslist→read guard, not a general confused-deputy defense. Roles are explicit per-tooldeputyblocks (schema v2); without one, the fixed nameslist_files/list_directory(discover) andread_file(use) apply as a compatibility mapping. Tools with no bound role run no check from this feature (their normal policy checks still apply), and clients sharing one child process share the set.- Response-body DLP/redaction, HTTP/SSE transport, and LLM-based moderation belong to other layers — see the positioning note at the top.
See the security model before choosing a deployment policy.
Build and development
cargo build --locked --release --bins
cargo test --locked
The minimum Rust version is 1.99.0; rust-toolchain.toml pins the toolchain used
for development and CI. Binary analysis coverage and OS sandbox support have
platform constraints documented in the user guide. Python 3 is needed for
integration fixtures, and Docker is needed for container tests.
See Development for verification commands and workflow responsibilities, and Releasing for publication procedures.
For existing installations, see migration to mcp-writ.
License
Licensed under the MIT License. See LICENSE.
Documentation
インストール
This server does not publish a one-line install command.
Open the repository installation guide設定
{
"mcpServers": {
"filesystem": {
"command": "mcp-writ",
"args": [
"run",
"--policy", "/opt/mcp-config/policy.kdl",
"--audit-log", "/opt/mcp-logs/audit.jsonl",
"--", "mcp-server-filesystem", "/srv/mcp-data"
],
"env": {}
}
}
}