Blog · MCP Server Security

MCP server Web Serial security — serial command injection, port enumeration fingerprinting, and persistent port access

The Web Serial API gives browser-based MCP PWAs direct bidirectional access to serial port-connected devices — Arduino microcontrollers, GPS receivers, industrial PLCs, medical instruments, and cellular modems. Once access is granted to a port, the browser retains it across sessions. An MCP tool response that controls the serial write payload can send arbitrary AT commands, raw binary protocols, or bootloader-level firmware update commands to physical hardware connected to the user's machine.

Serial command injection from tool output

The Web Serial API's WritableStream sends raw bytes to the connected serial device. There is no protocol framing at the browser level — whatever bytes the JavaScript writes go directly to the device's serial input. For devices that interpret text commands (AT commands for modems, G-code for CNC machines, SCPI for lab instruments), passing tool result strings directly to the write stream is equivalent to giving the tool unrestricted command execution on that device.

// DANGEROUS: serial write payload from MCP tool output
async function sendCommand(port, toolResult) {
  const writer = port.writable.getWriter();
  const encoder = new TextEncoder();

  // Attacker controls toolResult.command:
  const command = toolResult.command + '\r\n';
  await writer.write(encoder.encode(command));
  writer.releaseLock();
}

// MCP tool sends: { "command": "ATD+15551234567" }
// Modem dials the number — unexpected call from user's device.

// MCP tool sends: { "command": "M104 S280\nM109 S280" }
// 3D printer heats hotend to 280°C — potential fire hazard if unattended.

// MCP tool sends: { "command": "WRITE_FLASH 0x0800 [binary]" }
// Microcontroller bootloader receives firmware update — device bricked or replaced.

// CORRECT: command allowlist with structured parameters
const ALLOWED_COMMANDS = {
  'read_temperature': (params) => `READ TEMP ${parseInt(params.sensor)}\r\n`,
  'set_led_color':    (params) => `LED ${clampByte(params.r)} ${clampByte(params.g)} ${clampByte(params.b)}\r\n`,
  'get_status':       ()       => `STATUS\r\n`,
};

async function sendCommand(port, toolResult) {
  const builder = ALLOWED_COMMANDS[toolResult.action];
  if (!builder) throw new Error(`Unknown action: ${toolResult.action}`);

  const command = builder(toolResult.params || {});  // params validated inside builder
  const writer = port.writable.getWriter();
  await writer.write(new TextEncoder().encode(command));
  writer.releaseLock();
}

function clampByte(val) {
  const n = parseInt(val);
  if (isNaN(n) || n < 0 || n > 255) throw new Error('Invalid byte value');
  return n;
}

Serial commands have no undo: Unlike HTTP requests that can be rate-limited or reverted, serial commands execute immediately on physical hardware. A bootloader erase command, an actuator trigger, or a dangerous parameter write happens the moment the bytes arrive at the device. Physical damage, component failure, or safety hazard can result from a single attacker-controlled serial write.

Port enumeration as hardware fingerprinting

navigator.serial.getPorts() returns all serial ports that the user has previously granted permission for, without requiring a new user gesture. The list of granted ports — their vendor ID, product ID, and USB serial number — forms a stable and unique hardware fingerprint that persists across browser restarts.

// navigator.serial.getPorts() — no gesture required, returns all granted ports
const ports = await navigator.serial.getPorts();

const fingerprint = await Promise.all(ports.map(async (port) => {
  const info = await port.getInfo();
  return {
    usbVendorId: info.usbVendorId,   // 0x2341 = Arduino, 0x10C4 = Silicon Labs
    usbProductId: info.usbProductId, // specific device model
    // usbSerialNumber (when available): unique per physical device
  };
}));

// Example result:
// [
//   { usbVendorId: 0x2341, usbProductId: 0x0043 },  // Arduino Uno
//   { usbVendorId: 0x0403, usbProductId: 0x6001 },  // FTDI serial adapter
//   { usbVendorId: 0x10C4, usbProductId: 0xEA60 },  // CP2102 (common in IoT devices)
// ]

// This combination uniquely identifies the developer's hardware setup.
// It persists across: browser restart, private mode, VPN, cookie clear.

// Revealed information:
// - Whether the user has lab/industrial equipment (FTDI adapters = test equipment, PLCs)
// - Medical device connections (specific VID/PID combinations for FDA-regulated hardware)
// - Whether user is a developer (Arduino, Raspberry Pi UART)

// DEFENSE: never call getPorts() in MCP tool handlers
// Only call requestPort() when the user explicitly initiates serial connection
// Permissions-Policy: serial=() in iframes that render tool output

Baud rate and serial options injection

Opening a serial port requires specifying baud rate, data bits, stop bits, parity, and flow control. If these parameters come from tool output, an attacker can specify settings that mismatch the device's expected configuration — causing communication errors — or intentionally set configurations that trigger device-specific behavior. Some devices respond differently to unexpected baud rates or parity settings at the protocol level.

// DANGEROUS: port open options from tool output
const options = toolResult.serialOptions;
await port.open({
  baudRate: options.baudRate,   // attacker: 4800 instead of 115200 — data corruption
  dataBits: options.dataBits,   // attacker: 7 instead of 8 — framing errors
  stopBits: options.stopBits,   // attacker: 2 — protocol mismatch
  parity: options.parity,       // attacker: 'even' — checksum failures
  flowControl: options.flowControl  // attacker: 'hardware' — RTS/CTS signal manipulation
});

// At 4800 baud instead of 115200: bytes arrive garbled, device may interpret
// partial bytes as commands with unexpected values.
// On some GPS receivers: changing baud rate via NMEA command permanently
// reconfigures the device — requires physical reset to restore.

// CORRECT: hardcoded port options based on known device type
const DEVICE_OPTIONS = {
  arduino_uno:   { baudRate: 9600,   dataBits: 8, stopBits: 1, parity: 'none' },
  industrial_plc: { baudRate: 19200,  dataBits: 8, stopBits: 1, parity: 'even' },
  gps_receiver:  { baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none' },
};

const deviceType = validateDeviceType(toolResult.deviceType);
await port.open(DEVICE_OPTIONS[deviceType]);  // all options from trusted constants

Persistent port access after session end

Granted serial port permissions persist in the browser storage. After initial permission grant (via requestPort()), subsequent getPorts() calls return the port — and if the port is not explicitly released, another MCP tool session (or an injected script) can open it without user interaction. An MCP tool that establishes a long-running serial connection and does not close it on session end leaves the port accessible to subsequent tool invocations.

// DANGEROUS: port opened but never closed at tool session end
async function startMonitoring(port) {
  await port.open({ baudRate: 115200 });
  const reader = port.readable.getReader();

  // Background read loop — never terminates explicitly
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    processData(value);
  }
  // If the MCP session ends without explicit cleanup, the port stays open.
  // Next tool invocation can call port.readable.getReader() and read live data.
}

// CORRECT: AbortController + explicit cleanup
async function startMonitoring(port, mcpSession) {
  const abortController = new AbortController();

  await port.open({ baudRate: 115200 });
  const reader = port.readable.getReader();

  mcpSession.on('end', async () => {
    abortController.abort();
    reader.cancel();
    await port.close();
    // Port is closed — no other code can open it without a new user gesture
    // (because close() releases the exclusive lock)
  });

  // Read with AbortSignal:
  try {
    while (!abortController.signal.aborted) {
      const { value, done } = await reader.read();
      if (done) break;
      processData(value);
    }
  } finally {
    reader.releaseLock();
    if (port.readable) await port.close();
  }
}

Web Serial security — risk comparison

PatternAttackPhysical consequenceDefense
Serial write payload from tool output Attacker crafts AT commands, G-code, SCPI, or binary protocol commands Modem dials, printer overheats, PLC actuates, microcontroller reflashed Command allowlist; parameterized command builders; never pass raw strings to serial write
navigator.serial.getPorts() in tool handler Returns vendor/product IDs of all granted ports without user gesture Hardware fingerprint revealing lab equipment, medical devices, development tools Never call getPorts() in tool result handlers; only on explicit user action
Port open options from tool output Mismatched baud/parity causes data corruption; some devices permanently reconfigure on unexpected serial settings Device communication failure; permanent reconfiguration requiring physical reset Hardcode all port open options per known device type; never accept baud rate from tool
Port not closed on tool session end Next tool invocation reads live serial data from still-open port Continuous data exfiltration from sensor/instrument across tool sessions Explicit port.close() in session cleanup; use AbortController to terminate read loops

SkillAudit findings for Web Serial usage

CRITICAL Serial write payload (command string or binary) from MCP tool output — attacker tool response controls bytes written to serial-connected device; arbitrary command execution on industrial controllers, modems, firmware bootloaders, lab instruments. Score: −24.
HIGH navigator.serial.getPorts() called in tool response handler — all previously-granted serial port vendor/product IDs returned without user gesture; hardware fingerprint exposing lab, medical, and development equipment. Score: −20.
HIGH Port open options (baudRate, parity, flowControl) from tool output — attacker-controlled serial configuration causes data corruption or device-specific protocol misbehavior; some devices permanently change configuration on unexpected serial settings. Score: −18.
HIGH Serial port not closed at tool session end — port lock held across sessions; subsequent tool code reads live instrument data without new user gesture; continuous exfiltration from sensor or instrument. Score: −16.
MEDIUM No Permissions-Policy: serial=() on tool-output rendering contexts — tool content in iframes has access to Web Serial API including port enumeration. Score: −10.

Audit your MCP server Web Serial security

SkillAudit detects serial write calls with tool-derived payloads, getPorts() in tool handlers, tool-derived port open options, missing port close on session end, and absent Permissions-Policy serial restrictions. Free audit in 60 seconds.

Free audit →