Back to Documentation

Architecture: Secure MCP Plugin Execution

A detailed implementation concept for secure MCP plugin execution

Last updated: 1/15/2026

Status: Draft / Implementation

1. Overview

This document outlines the architecture for safely executing untrusted or semi-trusted MCP (Model Context Protocol) servers. The system uses a "Defense in Depth" strategy, combining OS-level User Separation (for network control) with Bubblewrap Sandboxing (for filesystem control).

The core design philosophy is Privilege Separation: The massive, complex Chat Server runs unprivileged, while a tiny, audited SUID Helper manages the transition to secure sandboxes.

2. The Architecture Stack

The execution chain consists of four distinct stages:

  1. The Controller (Chat Server):
  • Role: Logic & Orchestration.
  • Privilege: Standard User (app-user).
  • Network: Full Access.
  • Action: Requests a plugin spawn (e.g., "Start Weather Plugin").
  1. The Switchboard (SUID Launcher):
  • Role: Security Gateway.
  • Privilege: Root (via SUID bit), efficiently drops to Target User.
  • Action: Maps the "Profile" (e.g., external) to a System UID (e.g., 999) and maps the specific .so library to a generic path.
  1. The Jail (Bubblewrap):
  • Role: Filesystem & Namespace Isolation.
  • Privilege: Target User (e.g., mcp-external).
  • Action: Constructs a temporary, empty root filesystem. Binds only necessary libraries and the generic plugin.
  1. The Worker (Generic Loader):
  • Role: Execution.
  • Privilege: Target User (Sandboxed).
  • Action: dlopen("/app/plugin.so") and connects via Stdio.

3. Security Layers

Layer A: Network Isolation (IPTables + User ID)

We do not use complex container networking. We use standard Linux kernel filtering based on the Process Owner (UID).

  • Profiles:

  • disconnected (UID: 65534 / nobody): Uses bwrap --unshare-net. Physically impossible to send packets.

  • local (UID: 998 / mcp-local): Allowed to talk to Docker Subnets & Localhost. Blocked from WAN.

  • external (UID: 999 / mcp-external): Blocked from Localhost/LAN. Allowed to talk to WAN.

  • Implementation: Static iptables rules loaded at boot.

# Example: Block External Profile from Localhost
iptables -A OUTPUT -m owner --uid-owner mcp-external -d 127.0.0.0/8 -j REJECT

Layer B: Filesystem Isolation (Bubblewrap)

The worker process sees a constructed reality. It does not see /home, /var, or /etc.

  • Composition:

  • /usr, /lib: Read-only (from host).

  • /tmp: Tmpfs (vanishes on exit).

  • /app/plugin.so: Read-only bind mount of the specific host library (e.g., /opt/plugins/weather.so).

  • Benefit: A compromised "Weather" plugin cannot physically load or read the "Finance" plugin, as the file does not exist in its namespace.

Layer C: The Bridge (Unix Sockets)

For "Local" profiles needing database access, we avoid TCP. We map the Unix Domain Socket file into the sandbox.

  • Host Path: /var/run/postgresql/.s.PGSQL.5432
  • Sandbox Path: /run/postgresql/.s.PGSQL.5432
  • Result: Granular access to Postgres only, with no network stack required.

4. Implementation Guide

A. System Setup

Create the dedicated "Profile Users" without login shells.

sudo useradd -r -s /bin/false mcp-local     # UID ~998
sudo useradd -r -s /bin/false mcp-external  # UID ~999

B. The Rust SUID Launcher

The launcher is a small Rust binary owned by root with the SetUID bit (chmod u+s launcher).

Key Responsibilities:

  1. Sanitization: Clear ENV variables.
  2. Mapping: Resolve profile_name -> target_uid.
  3. Privilege Drop: Use pre_exec to switch identities before execution.

Pseudo-Code Logic:

use std::process::Command;
use std::os::unix::process::CommandExt; // for pre_exec

fn spawn(profile: &str, plugin_path: &str) {
    // 1. Determine Target Identity
    let (uid, gid, net_flag) = match profile {
        "external" => (999, 999, "--share-net"),
        "disconnected" => (65534, 65534, "--unshare-net"),
        _ => panic!("Invalid profile"),
    };

    let mut cmd = Command::new("bwrap");

    // 2. Configure Sandbox
    cmd.arg(net_flag)
       .arg("--ro-bind").arg("/usr").arg("/usr")
       // ... other base mounts ...
       // 3. Inject the specific plugin as the generic alias
       .arg("--bind").arg(plugin_path).arg("/app/plugin.so")
       .arg("/bin/generic-loader");

    // 4. The Critical Privilege Drop
    unsafe {
        cmd.pre_exec(move || {
            // Drop Group first, then User
            libc::setgid(gid);
            libc::setuid(uid);
            Ok(())
        });
    }

    cmd.spawn().expect("Launch failed");
}

C. The Generic Loader

A dumb executable that expects exactly one file to exist.

fn main() {
    // 1. Load the "Phantom" Library
    let lib = unsafe { libloading::Library::new("/app/plugin.so") };
    
    // 2. Find the entry point
    let entry: Symbol<fn()> = unsafe { lib.get(b"mcp_main") };
    
    // 3. Hand over control
    entry();
}


5. Lifecycle Summary

  1. Request: Chat Server calls ./launcher --profile=external --plugin=weather.
  2. Elevation: Launcher starts as app-user but runs with Effective UID root.
  3. Setup: Launcher calculates paths and arguments.
  4. Drop: Launcher calls fork(). Child calls setuid(999).
  5. Isolation: Child execs bwrap. Namespace is scrubbed.
  6. Binding: bwrap binds /opt/plugins/weather.so to /app/plugin.so.
  7. Execution: bwrap execs generic-loader.
  8. Runtime: Loader runs as UID 999. IPTables enforces "External Only" traffic rules.