Skip to main content
Glama
gil906

SmartThings MCP Server

by gil906
README.md
# SmartThings MCP Server

An [MCP](https://modelcontextprotocol.io/) server for Samsung SmartThings, exposing
devices, scenes, notifications and **full CRUD on Rules (Routines)** over
streamable-HTTP.

Built with [FastMCP](https://github.com/modelcontextprotocol/python-sdk). OAuth2 with
automatic token refresh — no expiring Personal Access Tokens anywhere.

<p align="center">
  <img src="docs/architecture.svg" alt="Architecture: MCP client to smartthings-mcp to the SmartThings Cloud API, and the Rules vs Routines limitation" width="100%">
</p>

## Why

Most SmartThings MCP servers only read devices and fire scenes. This one also
creates, updates, deletes and executes **Rules**, the automation engine behind
Routines — which is what you actually need to let an LLM build home automations.

## ⚠️ Rules vs Routines — read this before filing a bug

| Thing | Visible via API? | Manageable? |
|---|---|---|
| Rules created by this server (`create_routine`) | ✅ | ✅ full CRUD + execute |
| Routines created in the SmartThings **phone app** | ❌ never | ❌ app only |

`list_rules` returning `[]` is **expected** if you have only ever created Routines in
the mobile app. It is not an authentication failure. This is a documented Samsung
platform limitation that no client can work around:

> "Automatic routines ("rules") you create in the SmartThings app are a superset of
> what you can create with the Rules API. Routines created in the app will not appear
> when sending a GET request to `https://api.smartthings.com/v1/rules/`."
> — [SmartThings docs](https://developer.smartthings.com/docs/automations/getting-started-with-automations/)

## Tools

| Group | Tools |
|---|---|
| Devices | `list_devices`, `get_device_status`, `control_device` |
| Scenes | `list_scenes`, `execute_scene` |
| Locations | `list_locations` |
| Notifications | `send_notification`, `create_alert_switch` |
| Rules | `list_rules`, `get_rule`, `create_routine`, `update_routine`, `delete_routine`, `execute_routine` |

**Scenes are read-only by design.** SmartThings exposes no write scope for scenes
(`w:scenes` is rejected outright), so scenes can be listed and executed but never
authored through the API.

### Listing and controlling devices

<p align="center">
  <img src="docs/demo-devices.svg" alt="Example output of list_devices and control_device" width="100%">
</p>

### Building an automation

<p align="center">
  <img src="docs/demo-rules.svg" alt="Example lifecycle: create_routine, list_rules, execute_routine, delete_routine" width="100%">
</p>

> Device names, IDs and rule IDs in these examples are fictional.

## Setup

1. Create an [OAuth-In SmartApp](https://developer.smartthings.com/docs/connected-services/oauth-integrations/)
   with these scopes:

   ```
   r:devices:* x:devices:* r:scenes:* x:scenes:* r:locations:*
   r:rules:* w:rules:* x:rules:*
   ```

2. Configure credentials:

   ```bash
   cp .env.example .env
   # fill in SMARTTHINGS_CLIENT_ID and SMARTTHINGS_CLIENT_SECRET
   ```

3. Authorize once to mint the refresh token:

   ```bash
   python oauth_setup.py
   ```

   This opens a local loopback listener (default port `9444`) and writes
   `data/tokens.json`. If your SmartThings app requires a public HTTPS callback
   instead, use `oauth_capture.py` with `OAUTH_REDIRECT_URI` set.

4. Run it:

   ```bash
   docker compose up -d --build
   ```

   The server listens on `http://localhost:8085/mcp`.

## Client configuration

```json
{
  "mcpServers": {
    "smartthings": {
      "type": "http",
      "url": "http://localhost:8085/mcp"
    }
  }
}
```

The server is **stateless streamable-HTTP**: POST JSON-RPC with
`Accept: application/json, text/event-stream`. No `mcp-session-id` header is
required; responses are returned as SSE (`event: message\ndata: {...}`).

## Writing rules

`rule_json` is a JSON **string** containing the Rules API `actions` **array** only —
`name` and `locationId` are added by the tool.
Schema: <https://developer.smartthings.com/docs/rules/rules-api>

A harmless rule, safe to use when validating `execute_routine`:

```json
[{"if": {"equals": {"left": {"integer": 1}, "right": {"integer": 1}},
  "then": [{"sleep": {"duration": {"value": {"integer": 1}, "unit": "Second"}}}]}}]
```

A real rule — when one switch turns on, turn another off:

```json
[{"if": {"equals": {
    "left": {"device": {"devices": ["<deviceId>"], "component": "main",
             "capability": "switch", "attribute": "switch"}},
    "right": {"string": "on"}},
  "then": [{"command": {"devices": ["<otherDeviceId>"],
            "commands": [{"component": "main", "capability": "switch", "command": "off"}]}}]}}]
```

> ⚠️ **`execute_routine` runs the rule's actions for real, immediately.** It does not
> simulate. If any of your devices are power switches for machines you care about,
> validate with the `sleep` rule above rather than a `command` action.

## Authentication notes

OAuth2 **only**. `data/tokens.json` must contain all three of `access_token`,
`refresh_token`, and a real future `expires_at`. A background keep-alive loop
(`KEEPALIVE_HOURS`, default 12h) refreshes proactively so the refresh token never goes
stale through disuse.

**Personal Access Tokens are intentionally not supported.** Since December 2024,
SmartThings PATs expire 24 hours after creation, making them unusable for a
long-running server. There is no PAT fallback and no PAT setting — every request,
including all Rules calls, uses the auto-refreshing OAuth token.

## Troubleshooting

**401 on rules calls.** In order:

1. Check `data/tokens.json` has all three keys and a future `expires_at`.
2. Confirm `locationId` is being sent — SmartThings returns a bare-HTML **401**
   (not a 400) when `locationId` is missing from `/rules` requests, which makes a
   simple missing-parameter bug look like an auth failure.
3. Restart the container to force a refresh.
4. Last resort: re-run `oauth_setup.py`.

**Endpoint quirks** (already handled — don't "correct" them back):

- create: `POST /rules?locationId=...`
- execute: `POST /rules/execute/{ruleId}?locationId=...` (**not** `/rules/{id}/execute`)

**Container reports unhealthy.** The MCP endpoint only answers POST, so an
HTTP healthcheck against `/` returns 404. Use the TCP check in `docker-compose.yml`.

**Env changes not taking effect.** `docker compose up -d --force-recreate` — a plain
`docker restart` will not re-read `.env`.

## License

MIT