Skip to main content
Glama
tuya-local-mcp

tuya-local-mcp

by ethanj2k

tuya-local-mcp

An MCP server that controls Tuya / Smart Life smart home devices over your local network. Once set up, no request touches Tuya's cloud — commands go straight to the device on your LAN, so they keep working with the internet down and aren't subject to cloud API rate limits.

Built on tinytuya.

The one catch, up front

"Local control" still requires one trip through Tuya's cloud. Each device has a 16-character local_key that's provisioned during pairing and is never broadcast on the network — you can't sniff it, derive it, or guess it. You fetch it once from the Tuya IoT Platform, and after that everything is local, forever.

There is no way around this short of reflashing the hardware with ESPHome or Tasmota. If you'd rather avoid the developer-console setup entirely, use a cloud-based Tuya MCP server instead; this project won't save you that step.

Related MCP server: HueMCP

Install

Requires Python 3.10+.

git clone https://github.com/ethanj2k/tuya-local-mcp
cd tuya-local-mcp
python -m venv .venv && . .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install -e .

Setup

1. Check your devices are visible

Before touching any credentials, confirm the devices are actually reachable. This listens for the UDP beacons Tuya devices broadcast every few seconds and sends nothing:

python scripts/passive_scan.py 15
3 device(s):

  192.168.1.50    udp/6667  beacons=3   proto=3.3   id=bf0000000000000000aaaa
  192.168.1.51    udp/6667  beacons=2   proto=3.4   id=bf0000000000000000bbbb
  192.168.1.52    udp/6667  beacons=3   proto=3.3   id=bf0000000000000000cccc

No output? See Troubleshooting discovery — on Windows the usual cause is the firewall, not the devices.

2. Get your local keys

  1. Create an account at iot.tuya.com.

  2. Cloud → Development → Create Cloud Project. Pick the data centre matching your region and "Smart Home" as the industry.

  3. In the project, Devices → Link App Account and scan the QR code with the Smart Life / Tuya Smart app. This imports the devices you already own.

  4. Grab one device's Virtual ID from the app: tap the device → pencil icon → Device Information. tinytuya needs one id to bootstrap the list.

  5. Fetch the keys. Either the stock interactive wizard:

python -m tinytuya wizard

…or the non-interactive fetcher bundled here, which takes the same inputs from a .env file. Useful in containers, CI, or anywhere a prompt is awkward:

cp .env.example .env    # fill in the four values
python scripts/fetch_keys.py
querying Tuya Cloud (region us)...
cloud returned 6 device(s), 6 with a local key
listening 20s for LAN addresses (transmitting nothing)...
matched 6 device(s) to a LAN address

wrote devices.json  (contains local keys - do not commit)

  Living Room Lamp             192.168.1.50    v3.3  key=yes
  Desk Plug                    192.168.1.51    v3.4  key=yes

Either way you end up with devices.json. It contains your local keys — never commit it. This repo's .gitignore already excludes it, along with .env.

3. Point the server at the file

The server looks for snapshot.json then devices.json in the current directory, your home directory, and ~/.tinytuya/. Override with TUYA_DEVICES_FILE.

4. Wire it into your MCP client

{
  "mcpServers": {
    "tuya-local": {
      "command": "/absolute/path/to/.venv/bin/python",
      "args": ["-m", "tuya_local_mcp"],
      "env": {
        "TUYA_DEVICES_FILE": "/absolute/path/to/devices.json"
      }
    }
  }
}

On Windows the command is C:\\path\\to\\.venv\\Scripts\\python.exe.

Tools

Tool

Writes?

Description

list_devices

no

Every device in the registry — names, ids, IPs, protocol versions. Local keys are never returned.

discover_devices

no

Passively listen for devices broadcasting on the LAN. Flags any not in your registry.

get_status

no

Read one device's datapoints, plus a decoded reading of the common ones.

get_all_status

no

Read every device; per-device errors don't fail the whole call.

set_power

yes

Turn a device on or off.

set_brightness

yes

Set a light's brightness, 0–100%.

set_color

yes

Set a light's colour from hex (#FF8800 or f80).

set_color_temp

yes

White mode at a colour temperature, 0 (warm) to 100 (cool).

set_datapoint

yes

Write a raw datapoint — the escape hatch for fans, curtains, valves, heaters.

Devices are addressed by friendly name, device id, or IP. Name matching is case-insensitive and accepts unique substrings, so "desk" finds "Desk Plug". Ambiguous matches raise an error listing the candidates rather than picking one.

Read-only mode

Set TUYA_READ_ONLY=1 and every write tool refuses. Discovery and status reads still work. Useful when you want an assistant that can answer "is the garage light on?" without being able to act, or while you're still building trust in a setup.

Configuration

Variable

Default

Meaning

TUYA_DEVICES_FILE

auto-detected

Path to devices.json or snapshot.json.

TUYA_READ_ONLY

0

1 blocks all state changes.

TUYA_CONNECT_TIMEOUT

5

Per-device socket timeout, seconds.

TUYA_CACHE_TTL

10

Seconds to cache status reads. 0 disables.

TUYA_DISCOVERY_TIMEOUT

12

Default listen window for discover_devices.

Troubleshooting discovery

If passive_scan.py hears nothing, work down this list before concluding the devices are offline.

Windows: allow the base interpreter, not the venv one. This is the single easiest thing to lose an hour to, and the obvious fix is the wrong one.

Windows Firewall allows inbound traffic per program path. A venv's Scripts\python.exe on Windows is only a shim — the process that actually binds the socket is the base interpreter it was created from. So a rule naming .venv\Scripts\python.exe matches nothing, and your scan stays empty with no prompt and no error.

Confirm the real image path of whatever interpreter you're running:

.\.venv\Scripts\python.exe -c "import ctypes,sys; b=ctypes.create_unicode_buffer(1024); ctypes.windll.kernel32.GetModuleFileNameW(None,b,1024); print('reported:',sys.executable); print('actual  :',b.value)"
reported: C:\...\project\.venv\Scripts\python.exe
actual  : C:\Users\you\AppData\Local\Programs\Python\Python313\python.exe

Write the rule against the actual path (elevated PowerShell):

New-NetFirewallRule -DisplayName "Tuya LAN discovery" -Direction Inbound `
  -Program "C:\Users\you\AppData\Local\Programs\Python\Python313\python.exe" `
  -Protocol UDP -LocalPort 6666,6667,7000 -Action Allow

Because the rule lands on the base interpreter, it applies to every venv built from that install — convenient, but worth knowing you're opening those ports for all of them.

To see which interpreters are already allowed:

Get-NetFirewallApplicationFilter | Where-Object { $_.Program -like '*python*' } |
  ForEach-Object { $_.Program }

Note this only affects discovery. Device control is outbound TCP on port 6668 and works fine without any inbound rule — so a blocked scan does not mean a broken install. If your registry already has current IPs, everything else works.

Devices on another network segment. Beacons are broadcast and do not cross subnets or VLANs. IoT devices parked on a guest network won't be visible from your main one.

Another listener holds the ports. Home Assistant, a second copy of this script, or tinytuya's own scanner will contend for UDP 6666/6667. passive_scan raises if it can't bind anything.

Beacons are periodic. A 5-second window can genuinely miss a device. Try 30 seconds before worrying.

Notes and known limits

Protocol 3.4 / 3.5 devices use a session-key handshake rather than the local key directly. tinytuya supports them, but they're less battle-tested than 3.3 — if exactly one device misbehaves, check its version first.

Local keys rotate whenever a device is removed and re-paired in the app. Symptom is error 914; fix is re-running the wizard.

One connection at a time. Most Tuya devices accept a single TCP connection, so this server opens and closes a socket per call rather than holding one open. If a call times out, check nothing else (Home Assistant, another script) is polling that device continuously.

Datapoint numbering is per-product. The bulb helpers cover the two common layouts (datapoints 1–5 on older bulbs, 20–24 on newer). For anything else, get_status shows the raw datapoints and set_datapoint writes them.

Discovery is best-effort. Beacons are periodic, so a short listen window may miss devices that are simply between broadcasts. Increase the timeout rather than concluding a device is offline. A 20-second scan and a 75-second scan of the same network can differ by several devices.

Zigbee and BLE sub-devices are not locally controllable by this server. Your cloud account will list them, and fetch_keys.py will happily write them into devices.json, but they have no IP of their own — they're reached by routing a command through their gateway using node_id. That routing isn't implemented here. They're identifiable by a node_id or gateway_id field; scripts/inspect_registry.py separates them out for you.

A device switched off at the wall is invisible. It can't broadcast, so it gets no IP and can't be controlled — local control has no cloud fallback to hide this. This is the normal reason a cloud account lists far more devices than the scan finds, and it isn't a fault. Run scripts/inspect_registry.py to see which entries actually have an address.

Development

pip install -e ".[dev]"
pytest

The test suite runs entirely offline — no device, no network, no credentials. It includes a guard test asserting that passive_scan never calls send, sendto, sendall, or connect on a socket.

License

MIT

Available Tools

9 tools
discover_devicesA

Passively listen for Tuya devices broadcasting on the local network.

This transmits nothing - it only receives the beacons devices emit every few seconds. Useful for finding a device whose IP has changed, or for spotting devices that are on the network but missing from the registry.

Inputs: timeout. Returns: object

get_all_statusA

Read the state of every device in the registry.

Devices that fail are reported individually rather than failing the call, so one offline device does not hide the rest.

Inputs: no named parameters. Returns: object

get_statusA

Read the current state of a device.

device may be a friendly name, a device id, or an IP address. Returns the raw Tuya datapoints plus a best-effort interpretation of the common ones.

Inputs: device (required). Returns: object

list_devicesA

List every Tuya device in the local registry.

Returns names, device ids, IPs and protocol versions. Local keys are never returned. Use the name field when calling the other tools.

Inputs: no named parameters. Returns: object

set_brightnessA

Set a light's brightness as a percentage (0-100).

Inputs: device (required), percent (required). Returns: object

set_colorA

Set a light's colour from a hex string such as '#FF8800' or 'f80'.

Inputs: color (required), device (required). Returns: object

set_color_tempA

Set a light to white mode at a colour temperature percentage (0 warm, 100 cool).

Optionally set brightness in the same call.

Inputs: device (required), percent (required), brightness. Returns: object

set_datapointA

Write a raw Tuya datapoint.

The escape hatch for products whose datapoints the helper tools do not cover - fans, curtains, heaters, valves. Call get_status first to see which datapoints a device exposes and what types they hold.

Inputs: dp (required), value (required), device (required). Returns: object

set_powerA

Turn a device on or off.

switch_dp overrides the datapoint carrying the relay/switch. Leave it unset to let tinytuya pick, which is correct for the large majority of plugs, switches and bulbs.

Inputs: on (required), device (required), switch_dp. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4/5.0

    Scored across 9 tools

    Disambiguation5/5

    Each tool has a clearly distinct purpose: enumeration of registered devices (list_devices), passive network discovery (discover_devices), reading state for one or all devices (get_status/get_all_status), and distinct control actions (power, brightness, color, color temp, raw datapoint). The get_* and set_* families are parallel but not overlapping.

    Naming Consistency5/5

    All tool names follow a consistent lowercase snake_case verb_noun pattern: list_devices, discover_devices, get_status, get_all_status, set_power, set_brightness, set_color, set_color_temp, set_datapoint. No mixed conventions or vague verbs.

    Tool Count5/5

    Nine tools is well-scoped for a Tuya local control server: two device discovery/listing, two status reads, five control operations including a raw escape hatch. Each tool earns its place without redundancy or bloat.

    Completeness4/5

    The surface covers the core lifecycle of interacting with local Tuya devices: discovery, listing, status reading, and common control actions. The raw set_datapoint tool fills gaps for unsupported product types. A minor gap is the lack of a tool to add discovered devices to the registry or update device metadata.

    Maintenance

    ActivitySlowing
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      D
      maintenance
      Enables discovery and control of Philips Hue lighting devices via a local bridge using the CLIP v2 API, without any cloud dependency.
      10
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables control of Tuya smart lighting over the local home network, including reading device state, turning devices on or off by name, and switching between white and colour modes with RGB or temperature settings. Devices and credentials stay on the server and are scoped per household principal, so each caller only sees their own home's lights.
      MIT