SM

strumbyte/mcp-writ

Developer tools
0 stars 0 forks Quality 62 Trend 8

Policy enforcement, OS sandboxing, and JSON-RPC auditing for MCP servers

Overview

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

日本語版 / Japanese

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’ filesystem rules 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 global filesystem lists; per-tool filesystem/network is Auditor-only and defaults.syscalls is not applied.
  • Everywhere: in a normal run, the tool allowlist, args_schema, side_effect, and the secret-path overlay are checked on tools/call arguments; violations return a JSON-RPC error. tools-list-hash is compared against the tools/list response — on a mismatch the guard returns an error response and aborts the session. Under --dry-run tools/call violations are forwarded instead (logged observed), and a client-initiated tools/list pin mismatch is also forwarded rather than aborting. A defaults.environment allowlist restricts the child process’s environment variables at launch — including --dry-run and MCP_WRIT_SKIP_SANDBOX runs — and the parent environment is inherited unchanged when the node is absent.
  • Before spawn: binary-hash / entrypoint-hash pins on the launch target are verified, bound to the resolved executable / first payload argument, and re-verified immediately before exec — 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-policy records the gap as a // REVIEW: comment instead of fabricating a hash.

What it does not guarantee

  • Per-tool filesystem/network rules inspect RPC arguments; they are not a per-tool OS sandbox and do not cover server-internal access.
  • --dry-run runs 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_protection is opt-in — a per-process known_paths list→read guard, not a general confused-deputy defense. Roles are explicit per-tool deputy blocks (schema v2); without one, the fixed names list_files / list_directory (discover) and read_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

View this README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide

Configuration

{ "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": {} } } }