# DCP — Device Context Protocol

[![tests](https://github.com/device-context-protocol/dcp/actions/workflows/test.yml/badge.svg)](https://github.com/device-context-protocol/dcp/actions/workflows/test.yml)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![spec: v0.3 draft](https://img.shields.io/badge/spec-v0.3%20draft-orange)](SPEC.md)
[![arXiv](https://img.shields.io/badge/arXiv-2605.26159-b31b1b.svg)](https://arxiv.org/abs/2605.26159)

**Status:** Draft v0.3 — May 2026 · Hardware-validated on ESP32-WROOM-32

> A protocol that lets LLM agents safely control physical devices,
> down to dollar-class microcontrollers.
>
> Intent-level, transport-agnostic, capability-scoped. Compact wire format
> (sub-50-byte frames). Self-contained firmware: under 1 KB of RAM,
> ~28 KB of flash.
>
> Complementary to [MCP](https://modelcontextprotocol.io) — a reference
> Bridge translates DCP ↔ MCP so any MCP host (Claude Desktop, Claude Code,
> IDE assistants) works zero-config.

## Contents

- [Why DCP?](#why-dcp)
- [Design principles](#design-principles)
- [Architecture](#architecture)
- [Quickstart](#quickstart)
- [Add a feature in 5 steps](docs/ADDING_FEATURES.md)
- [Recipes — five ready-to-flash device skeletons](docs/RECIPES.md)
- [Wire format](#wire-format) · full [SPEC.md](SPEC.md)
- [Manifest](#manifest)
- [Roadmap](#roadmap)
- **Design rationale:** [docs/RATIONALE.md](docs/RATIONALE.md) — why
  not MCP-on-MCU, why not WoT, why not Matter.

## Why DCP?

MCP is excellent for SaaS tools, but assumes JSON-RPC over WebSocket and runtime
tool discovery. On an MCU with 32 KB of RAM, that's a non-starter.

DCP keeps MCP's mental model (manifest + tool calls) but:

- compiles to a compact CBOR wire format
- uses a static intent table (no runtime negotiation)
- moves safety enforcement to a Bridge process

A reference Bridge translates **DCP ↔ MCP**, so any MCP-compatible LLM works
out of the box. DCP is the last mile to physical hardware.

![Coverage of LLM-induced failure modes by each protocol's schema-level defenses](docs/paper/figures/hallucination.png)

*Why this matters in one chart: the protocol's schema decides how many
hallucinated or adversarial calls are stopped before any byte reaches a
device. DCP catches all six categories at the wire layer; the others
catch what their existing schema happens to cover.*

## Design principles

1. **Intent, not register.** `set_brightness(50%)`, not `write_pwm(pin=5, duty=128)`.
2. **Units in the protocol.** Every number declares a unit. No ambiguity.
3. **Static intent table.** Manifest known at compile time; runtime is pure binary.
4. **Safety lives in the Bridge.** Devices trust the Bridge; LLMs never see raw GPIO.
5. **Idempotent by default.** Non-idempotent intents must declare themselves.
6. **Transport-agnostic.** UART, BLE, MQTT, USB-CDC, WebSocket — one frame.

## Architecture

![DCP architecture](docs/paper/figures/arch.png)

The Bridge is the sole trust boundary. On every call it issues and
verifies capability tokens, enforces range/type/unit checks from the
manifest, and supports dry-run as a wire-format primitive. Devices
remain simple enough to fit on commodity microcontrollers; everything
the LLM is allowed to do is enforced before any byte traverses the
device boundary.

## Validated on real hardware

As of v0.3 the reference firmware is **measured-validated on two
physical boards** — an ESP32-WROOM-32 dev board over CH340 USB-Serial,
and an ESP32-S3 (LILYGO T-Panel S3) over the S3's native USB-Serial/JTAG
— both at 115 200 baud:

- 13/13 round-trip tests pass on each board (`tools/test_uart_roundtrip.py`)
- 88/88 Python unit & conformance tests pass
- Full lamp firmware: 295 KB flash, 22.7 KB globals on WROOM-32 — most
  of which is the Arduino-ESP32 runtime + FreeRTOS, not DCP
- **The DCP layer itself measures 27.6 KB of flash and 0.6 KB of RAM**
  over a baseline empty sketch — reproduce with
  `docs/paper/figures/measure_footprint.py`. The flash figure is over
  the original `<16 KB` design target (set before on-device HMAC was
  added); the RAM figure is well under it.
- The S3 run also exercises DCP over a native-USB CDC link rather
  than a USB-UART bridge chip — same firmware, no transport-specific
  code

![Static RAM footprint: measured DCP layer vs IoT-MCP reported peak memory](docs/paper/figures/footprint.png)

*Static RAM is the scarce resource on an MCU. The DCP layer's measured
0.6 KB of RAM sits two orders of magnitude under IoT-MCP's reported
74 KB peak memory. DCP's flash cost (27.6 KB, measured) is not plotted
— IoT-MCP does not report a comparable flash figure.*

See [docs/RATIONALE.md §7](docs/RATIONALE.md) for what the hardware
validation does and does not prove.

### Cross-compile clean across the ESP family (Xtensa + RISC-V + ESP8266)

The reference firmware is portable by design (Arduino `Stream` + a
software SHA-256, no SoC-specific code paths in `DCP.{h,cpp}`). It
cross-compiles for every current ESP32 variant *and* for ESP8266;
two of those targets are also runtime-validated on real boards, the
rest are build-validated pending hardware on the bench:

| Target            | ISA                   | Flash (lamp+blink) | Globals  | Status        |
|-------------------|-----------------------|--------------------|----------|---------------|
| ESP32-WROOM-32    | Xtensa LX6 (baseline) | 294 KB             | 22.7 KB  | runtime ✓     |
| ESP32-S3 (T-Panel)| Xtensa LX7            | 322 KB             | 22.7 KB  | runtime ✓ (native USB) |
| ESP32-C3          | RV32IMC               | 289 KB             | 13.4 KB  | builds ✓      |
| ESP32-C6          | RV32IMAC + HW-crypto  | 266 KB             | 14.0 KB  | builds ✓      |
| ESP32-H2          | RV32IMAC + 802.15.4   | 292 KB             | 14.0 KB  | builds ✓      |
| ESP32-P4          | RV32IMAFC dual-core   | 326 KB             | 22.0 KB  | builds ✓      |
| ESP8266 NodeMCU   | Xtensa LX106 (legacy) | 242 KB             | 28.9 KB  | builds ✓      |

All builds use Arduino-ESP32 core 3.3.8 / Arduino-ESP8266 core 3.x
+ the same `firmware/esp32/` library. The sketch picks PWM API at
compile time (`ledcAttach`/`ledcWrite` on ESP32, `analogWrite` on
ESP8266); the protocol layer itself has no `#ifdef`. Reproduce
with:

```bash
arduino-cli compile --clean --fqbn esp32:esp32:esp32c3 \
    --library firmware/esp32 firmware/esp32/examples/lamp
arduino-cli compile --clean --fqbn esp8266:esp8266:nodemcuv2 \
    --library firmware/esp32 firmware/esp32/examples/lamp
```

## Manifest

```yaml
dcp: 0.3
device:
  id:     lamp-kitchen-01
  model:  smart_lamp_v1
  vendor: example.dev

intents:
  - name: set_brightness
    params:
      level: { type: float, unit: percent, range: [0, 100] }
      fade:  { type: duration, unit: ms, default: 0 }
    capability: lamp.write
    idempotent: true
    dry_run: true

  - name: read_brightness
    returns: { type: float, unit: percent }
    capability: lamp.read

events:
  - name: motion_detected
    payload:
      confidence: { type: float, unit: ratio, range: [0, 1] }
    capability: lamp.read
```

`intent_id = crc16(name)` — manifests and firmware stay in sync without
coordination.

## Wire format

![DCP frame layout + on-wire size comparison](docs/paper/figures/wire_format.png)

A frame is a 6-byte fixed header + CBOR payload + an optional 16-byte
truncated HMAC-SHA256. Header fields:

| field       | meaning                                                          |
|-------------|------------------------------------------------------------------|
| `ver`       | 1 in v0.3                                                        |
| `kind`      | 0x01 call · 0x02 reply · 0x03 event · 0x04 error · 0x81 dry-run |
| `seq`       | client-chosen, echoed in reply                                   |
| `intent_id` | CRC-16/CCITT of intent name                                      |
| `cbor`      | CBOR map: params / return / event payload / error                |

Reply status codes: `ok`, `denied`, `range`, `busy`, `unknown_intent`, `capability_required`.

A typical `set_brightness(50)` call is 19 bytes on the wire; the MCP
JSON-RPC equivalent is approximately 180 bytes. The full normative spec
lives at [SPEC.md](SPEC.md).

## Adding a feature

See [docs/ADDING_FEATURES.md](docs/ADDING_FEATURES.md) for the full
5-step loop with a worked `blink(times, period)` example. The short
version: edit the manifest, add a C++ handler + binding, recompile,
flash, restart the MCP server — the LLM picks up the new tool
automatically. The Bridge needs no code change.

## Quickstart

```bash
# As a user — install from PyPI:
pip install "pydcp[mcp,serial]"            # or [mcp,serial,mqtt,ble] for all transports
dcp inspect examples/lamp_manifest.yaml    # parsed manifest summary
dcp serve   examples/lamp_manifest.yaml --simulator
```

```bash
# As a contributor — editable install from source:
git clone https://github.com/device-context-protocol/dcp.git
cd dcp
pip install -e ".[mcp,serial,mqtt,ble,dev]"
pytest                                     # all 88 tests
python examples/lamp_demo.py               # in-process bridge ↔ fake lamp
```

The PyPI package is named `pydcp` (the bare `dcp` is squatted by an
unrelated package). The import name is `dcp`. The protocol name is DCP.

### Run as an MCP server

The reference Bridge ships an MCP server that exposes each DCP intent as an
MCP tool. With ``--simulator`` it spins up an in-process fake device, so you
can demo with no hardware.

```bash
dcp serve examples/lamp_manifest.yaml --simulator               # no hardware
dcp serve examples/lamp_manifest.yaml --serial COM3             # real ESP32 over UART
dcp serve examples/lamp_manifest.yaml --mqtt broker.lan:1883 \  # MQTT
            --mqtt-prefix dcp/lamp-kitchen
dcp serve examples/lamp_manifest.yaml --ble AA:BB:CC:DD:EE:FF \ # BLE
            --ble-service 12345678-1234-5678-1234-567812345678
```

### Capability tokens (HMAC-SHA256)

For multi-tenant or scoped access, mint short-lived HMAC tokens and pass them
to the Bridge:

```bash
export DCP_SECRET=$(dcp token keygen)
dcp token mint --caps lamp.write,lamp.read --ttl 3600
# eyJjYXBzIjpb...sig
```

Tokens are verified by the Bridge on every call. The device sees only
already-authorized frames. Devices themselves do **not** verify signatures
in v0.2 — that requires on-device HMAC, which is on the roadmap.

To wire it into **Claude Desktop**, add this to your
``claude_desktop_config.json``:

```json
{
  "mcpServers": {
    "smart-lamp": {
      "command": "dcp",
      "args": [
        "serve",
        "C:/path/to/protocol/examples/lamp_manifest.yaml",
        "--simulator"
      ]
    }
  }
}
```

Then ask Claude *"set the lamp to 60% brightness"*. The call flow:

```
Claude ─MCP─▶ dcp serve ─Bridge─▶ Loopback ─DCP wire─▶ GenericSimulator
```

For production use, replace ``GenericSimulator`` with a real transport
(UART / MQTT / BLE — coming next).

## What's *not* in v0.3 (intentional)

- Multi-device atomic transactions
- Firmware OTA
- Mesh routing (use Thread / Zigbee underneath if you need it)
- LLM-side authentication (delegated to the MCP host's session model)
- Native CAN FD frames (ESP32-S3 TWAI is classic CAN; v0.4 ESP32-P4
  port enables true CAN FD)

## Cite

If you use DCP in academic work, please cite the arXiv preprint:

```bibtex
@misc{yang2026dcp,
  title        = {Device Context Protocol: A Compact, Safety-First Architecture
                  for LLM-Driven Control of Constrained Devices},
  author       = {Yang, Dongxu},
  year         = {2026},
  eprint       = {2605.26159},
  archivePrefix= {arXiv},
  primaryClass = {cs.NI},
  url          = {https://arxiv.org/abs/2605.26159},
}
```

A machine-readable [`CITATION.cff`](CITATION.cff) is also provided — GitHub
renders a "Cite this repository" button in the sidebar.

## License

MIT.

## Roadmap

- [x] Wire format + manifest parser
- [x] Reference Python Bridge with loopback transport
- [x] Lamp example
- [x] MCP server wrapper + CLI (`dcp serve`)
- [x] Generic in-process device simulator
- [x] UART transport (COBS framing + CRC-16)
- [x] ESP32 reference firmware (Arduino-compatible C++)
- [x] Design rationale ([docs/RATIONALE.md](docs/RATIONALE.md))
- [x] CI (GitHub Actions, Linux + Windows, py 3.11–3.13)
- [x] MQTT transport
- [x] HMAC-SHA256 capability tokens (Bridge-side enforcement)
- [x] Manifest compiler: `dcp codegen` (YAML → C header)
- [x] Compile-time `DCP_ID(name)` macro in firmware
- [x] BLE GATT transport (bleak)
- [x] Release prep: CONTRIBUTING / CHANGELOG / CoC / SECURITY / issue templates
- [x] On-device HMAC verification (per-frame signatures, ESP32 firmware)
- [x] ESP32 BLE peripheral example (NimBLE-Arduino)
- [x] Conformance test suite (golden frames, language-neutral YAML)
- [x] Codegen `--stubs`: emits handler signatures + binding table
- [x] Quickstart video script ([docs/QUICKSTART_VIDEO.md](docs/QUICKSTART_VIDEO.md))
- [x] Real-hardware validation on two boards (ESP32-WROOM-32 over
  CH340, ESP32-S3 / T-Panel over native USB), 13/13 round-trips each
- [x] Cross-compile clean on ESP32 RISC-V family (C3, C6, H2, P4) and ESP8266
- [x] Public repo at `device-context-protocol/dcp` (v0.3.0 released)
- [x] PyPI release (`pip install pydcp`, latest v0.3.1)
- [x] LLM-driven hallucination-rejection benchmark: 675 tool calls
  across 5 LLMs / 4 vendors, prompt-injection category instantiated
  from AgentDojo's attack templates. DCP catches 100% of capability-
  escalation and 78% of prompt-injection attempts vs 0–1% for MCP/
  IoT-MCP. See `tools/gen_llm_corpus.py` + `tools/bench_hallucination_empirical.py`.
- [x] DCP vs IoT-MCP wire-latency A/B on identical ESP32-S3 hardware:
  15.60 ms vs 15.59 ms median, within 5 µs. See
  `firmware/esp32/examples/iotmcp_echo/` + `tools/bench_latency_iotmcp.py`.
- [x] arXiv preprint published: [arXiv:2605.26159](https://arxiv.org/abs/2605.26159)
  (v0.3.1). Source bundle and rendered PDF also mirrored on the v0.3.1
  [release page](https://github.com/device-context-protocol/dcp/releases/tag/v0.3.1).
- [ ] T-Panel S3 + CAN bus demo (firmware ready, awaiting hardware)
- [ ] ESP32-P4 port for native CAN FD
- [ ] Multi-MCU footprint matrix (nRF52840, Cortex-M0+, RP2040)
