Skip to main content
Glama
syntechnology-engineering

ThingsBoard Lighting MCP Server

ThingsBoard Lighting MCP Server

MCP server ที่ทำให้ Claude อ่านสถานะและสั่งเปิด/ปิดไฟ 3 ดวงในห้อง innovation ผ่าน ThingsBoard ได้ด้วยภาษาธรรมชาติ

An MCP server that lets Claude read and switch the 3 lights in the innovation room through ThingsBoard, using natural language.

สถานะ / Status: ใช้งานได้จริงแล้ว ยืนยันกับอุปกรณ์จริงเมื่อ 2026-08-10 · Working, verified against real hardware on 2026-08-10 เทสต์ / Tests: 57 passing


สารบัญ / Table of Contents

  1. ทำอะไรได้ / What it does

  2. เริ่มใช้เร็ว / Quick start

  3. โครงสร้างโปรเจกต์ / Project layout

  4. Flow การทำงานทั้งหมด / Complete flow

  5. อธิบายโค้ดทีละไฟล์ / Module-by-module walkthrough

  6. สัญญาของ MCP tools / MCP tool contracts

  7. ข้อมูลอ้างอิงของ ThingsBoard / ThingsBoard reference

  8. การตั้งค่า / Configuration

  9. ต่อกับ Claude Desktop / Connecting Claude Desktop

  10. การทดสอบ / Testing

  11. แก้ปัญหา / Troubleshooting

  12. ต่อยอดอย่างไร / How to extend

  13. เหตุผลการออกแบบ / Design decisions

  14. ข้อจำกัดที่รู้อยู่ / Known gaps


Related MCP server: Claude-LMStudio-Bridge

1. ทำอะไรได้ / What it does

ไทย — server นี้เปิด "เครื่องมือ" (tool) ให้ Claude 2 ตัว Claude จะตัดสินใจเรียกเองตามที่ผู้ใช้พูด แล้วแปลผลกลับเป็นภาษาคน ประโยคที่ทดสอบผ่านกับไฟจริงแล้ว:

English — this server exposes 2 tools to Claude. Claude decides when to call them based on what the user says, then translates the result back into human language. These sentences are verified against the real lights:

พูดว่า / You say

Claude เรียก / Claude calls

ผล / Result

"ตอนนี้ไฟในห้อง innovation เปิดกี่ดวง"

get_lighting

"เปิดอยู่ทั้ง 3 ดวง (ซ้าย กลาง ขวา)"

"เปิดไฟซ้าย"

set_lighting(["left"], true)

ไฟดวงซ้ายติดจริง / left light physically turns on

"ปิดไฟกลาง"

set_lighting(["center"], false)

ไฟดวงกลางดับจริง / center light physically turns off

"เปิดไฟทั้งห้อง"

set_lighting(["left","center","right"], true)

ยิง RPC ครั้งเดียว ด้วย setStateLightAll


2. เริ่มใช้เร็ว / Quick start

# 1. ติดตั้ง dependency / install dependencies
npm install

# 2. สร้างไฟล์ config จาก template / create your config from the template
cp .env.example .env
#    แก้ .env ใส่ค่าจริง (ดูหัวข้อ 8) / edit .env with real values (see section 8)

# 3. build (คอมไพล์ TypeScript -> JavaScript) / build (compile TypeScript to JavaScript)
npm run build

# 4. รันเทสต์ทั้งหมด — ไม่ต้องต่อเน็ต ไม่แตะไฟจริง
#    run all tests — no network, no real lights touched
npm test

# 5. รัน server ด้วยมือเพื่อดูว่าขึ้นได้ / run the server manually to check it starts
npm start

สำคัญ / Important: npm start คือ node --env-file=.env build/src/index.js — flag --env-file จำเป็น เพราะโปรเจกต์นี้ไม่มีอะไรโหลด .env ให้เอง (ไม่มี dotenv) ถ้ารัน node build/src/index.js เปล่า ๆ จะพังทันทีพร้อมบอกชื่อตัวแปรที่ขาด

npm start is node --env-file=.env build/src/index.js. The --env-file flag is required because nothing in this project loads .env by itself (no dotenv dependency). Running node build/src/index.js bare will fail immediately, naming the missing variables.

ตอน server ขึ้นแล้วมันจะเงียบและรอ — นั่นถูกต้อง มันรอ JSON-RPC จาก stdin ไม่ใช่ค้าง Once running the server goes quiet and waits — that is correct. It is waiting for JSON-RPC on stdin, not hanging.


3. โครงสร้างโปรเจกต์ / Project layout

mcp-server-test/
├── src/                          โค้ดที่ ship จริง / production code only
│   ├── index.ts                  MCP wiring — ลงทะเบียน tool + ต่อ stdio
│   ├── config.ts                 อ่านและตรวจ environment variables
│   ├── lighting.ts               ตรรกะโดเมนล้วน ๆ (ไม่รู้จัก HTTP, ไม่รู้จัก MCP)
│   ├── tb-client.ts              ชั้นเดียวที่รู้จัก HTTP และ ThingsBoard
│   └── mock.ts                   อุปกรณ์ปลอมใน memory สำหรับเทสต์
│
├── test/                         เทสต์ทั้งหมด / all tests
│   ├── smoke.test.ts             พิสูจน์ว่า test harness ทำงาน
│   ├── config.test.ts            6 tests
│   ├── lighting.parse.test.ts    การแปลงค่า telemetry เป็น boolean
│   ├── lighting.read.test.ts     การนับดวง + cross-check
│   ├── lighting.set.test.ts      การสั่งไฟ + partial failure
│   ├── mock.test.ts              ความซื่อสัตย์ของอุปกรณ์ปลอม
│   ├── tb-client.test.ts         HTTP, auth, retry (ใช้ stub fetch ไม่แตะเน็ต)
│   └── index.handshake.test.ts   ยิง JSON-RPC จริงใส่ server ที่ spawn ขึ้นมา
│
├── build/                        ผลลัพธ์การคอมไพล์ (gitignored)
│   ├── src/index.js              ← entrypoint จริงที่ Claude Desktop เรียก
│   └── test/*.test.js            ← ที่ test runner มองหา
│
├── docs/superpowers/
│   ├── specs/                    เอกสารออกแบบ (ภาษาไทย) / design spec
│   └── plans/                    แผน implement ทีละ task / task-by-task plan
│
├── .env                          ค่าจริง — gitignored ห้าม commit
├── .env.example                  template ชื่อ key เท่านั้น ไม่มีค่า
├── tsconfig.json
└── package.json

ทำไม build/src/index.js ไม่ใช่ build/index.js / Why build/src/index.js

ไทย — เพราะ test/ อยู่นอก src/ ทำให้ tsconfig.json ต้องตั้ง rootDir: "." ไม่ใช่ "./src" ผลคือ output สะท้อนโครงสร้าง source ทั้งหมด → build/src/ กับ build/test/ ข้อดีที่ได้แถมมา: ของที่ ship แยกออกจาก test ชัดเจน (files: ["build"] ใน package.json จะรวม test ไปด้วย แต่มันอยู่แยกโฟลเดอร์ ตัดออกง่าย)

English — because test/ lives outside src/, tsconfig.json must set rootDir: "." rather than "./src". The output therefore mirrors the full source layout: build/src/ and build/test/. A useful side effect is that shipped code is cleanly separated from tests.

จุดที่ต้องตรงกันทั้ง 4 ที่ / Four places that must agree on this path:

ที่ / Place

ค่า / Value

package.jsonbin

./build/src/index.js

package.jsonscripts.start

node --env-file=.env build/src/index.js

test/index.handshake.test.tsspawn(...)

build/src/index.js

Claude Desktop config → args

...\build\src\index.js


4. Flow การทำงานทั้งหมด / Complete flow

4.1 ภาพรวมชั้นต่าง ๆ / Layer overview

graph TD
    U["ผู้ใช้ / User<br/>'เปิดไฟซ้าย'"] --> CD["Claude Desktop"]
    CD -->|"spawn child process<br/>+ env block"| IDX["src/index.ts<br/>(MCP wiring)"]
    CD <-->|"JSON-RPC over stdio"| IDX
    IDX -->|"validated args"| LIG["src/lighting.ts<br/>(domain logic)"]
    LIG -->|"LightingClient interface"| TB["src/tb-client.ts<br/>(HTTP only)"]
    LIG -.->|"TB_MOCK=1"| MK["src/mock.ts<br/>(in-memory)"]
    TB -->|"HTTPS + X-Authorization"| TBS["ThingsBoard 3.9.0"]
    TBS -->|"one-way RPC"| DEV["อุปกรณ์จริง / real device<br/>ไฟ 3 ดวง"]
    DEV -->|"telemetry"| TBS

หัวใจของการแบ่งชั้น / The key idea: lighting.ts ไม่ import อะไรจาก HTTP หรือ MCP เลย มันรับ LightingClient เข้ามาทาง argument ทำให้สลับระหว่างของจริงกับของปลอมได้โดยไม่แก้ตรรกะ และเทสต์ตรรกะทั้งหมดรันได้แบบไม่ต้องมีเน็ต

lighting.ts imports nothing from HTTP or MCP. It receives a LightingClient as an argument, so the real client and the mock are interchangeable without touching any logic — and every logic test runs offline.

4.2 Flow การอ่าน / Read flow — get_lighting

1. ผู้ใช้: "ไฟเปิดกี่ดวง"
   User: "how many lights are on"

2. Claude Desktop → tools/call { name: "get_lighting", arguments: {} }

3. index.ts handler → readLighting(client)

4. lighting.ts → client.getTimeseries([
       "state_left","state_center","state_right","state_all","any_on"
   ])                                    ← ขอ 5 key ใน request เดียว / one request

5. tb-client.ts
   a) ยังไม่มี token? → POST /api/auth/login  { username, password }
      No token yet?   → POST /api/auth/login
   b) GET /api/plugins/telemetry/DEVICE/{deviceId}/values/timeseries?keys=...
      header: X-Authorization: Bearer <token>
   c) ถ้าได้ 401 → login ใหม่ 1 ครั้ง แล้วลองซ้ำ 1 ครั้ง (ไม่วนลูป)
      On 401 → re-login once, retry once (never a loop)

6. ThingsBoard ตอบ / responds:
   { "state_left": [{"ts":1786343456770,"value":"ON"}], ... }

7. tb-client.ts แบนข้อมูล / flattens:
   เลือก entry ที่ ts มากสุดของแต่ละ key แล้วส่งค่าดิบออกไป **ไม่แปลงชนิด**
   picks the entry with the greatest ts per key, passes the raw value through uncoerced
   → { state_left: "ON", state_center: "ON", ... }

8. lighting.ts → parseTbBoolean("ON", "state_left") → true
   ค่าที่ไม่รู้จัก = โยน error ระบุชื่อ key **ไม่เดาว่าปิด**
   unknown value = throw naming the key, never assume off

9. นับจำนวนดวงที่เปิดจาก 3 key เดี่ยว **เท่านั้น**
   count from the 3 individual keys ONLY
   แล้วเอา state_all / any_on มา cross-check
   then cross-check against state_all / any_on

10. คืน LightingStatus → index.ts ห่อเป็น
    returns LightingStatus → index.ts wraps as
    { content: [{type:"text", text:"3 of 3 lights are on: left, center, right."}],
      structuredContent: { onCount:3, total:3, lights:{...}, ... } }

11. Claude อ่านแล้วตอบผู้ใช้เป็นภาษาไทย
    Claude reads it and answers the user in Thai

4.3 Flow การสั่ง / Write flow — set_lighting

1. ผู้ใช้: "ปิดไฟซ้าย" / User: "turn off the left light"

2. Claude Desktop → tools/call {
       name: "set_lighting",
       arguments: { lights: ["left"], on: false }
   }

3. ★ ด่านตรวจ zod / zod validation gate
   lights ต้องเป็น enum ["left","center","right"] และมีอย่างน้อย 1 ตัว
   ถ้า Claude ส่ง "ceiling" มา → ปฏิเสธที่นี่ ไม่ถึงโค้ดสั่งไฟ
   If Claude sends "ceiling" it is rejected HERE, before any switching code runs

4. index.ts handler → setLighting(client, ["left"], false)

5. lighting.ts ตรวจก่อนยิง RPC / validates BEFORE any RPC:
   - array ว่าง → throw
   - มีดวงซ้ำ → throw
   ทั้งสองกรณี **ไม่มี RPC ถูกส่งเลย** / no RPC is sent at all

6. เลือกวิธียิง / dispatch choice:
   ครบทั้ง 3 ดวง → setStateLightAll  ยิง **ครั้งเดียว**
   All three     → setStateLightAll  ONE call
   ไม่ครบ        → ยิงทีละดวงตามลำดับ LIGHT_IDS (ไม่ใช่ลำดับที่ caller ส่งมา)
   Subset        → one call per light, in LIGHT_IDS order (not caller order)

7. tb-client.ts → POST /api/plugins/rpc/oneway/{deviceId}
   body: { "method": "setStateLightLeft", "params": false }

8. ⚠️ ThingsBoard ตอบ 200 OK แค่ว่า "ส่งต่อให้อุปกรณ์แล้ว"
   ไม่ได้บอกว่าไฟขยับจริง — doc ของมันเขียนว่า "no visibility into device processing"
   อุปกรณ์ offline = คำสั่งหายเงียบ ๆ
   200 OK means only "forwarded to the device", NOT that the light moved.
   An offline device loses the command silently.

9. ★ เพราะข้อ 8 → อ่าน telemetry กลับมาใหม่ (วน flow ข้อ 4.2 ทั้งชุด)
   Because of step 8 → read the device back (full 4.2 flow again)

10. คืนค่าที่ **อ่านได้จริง** ไม่ใช่ค่าที่สั่งไป
    Returns what was actually READ, not what was requested
    → "2 of 3 lights are on: center, right."

4.4 เมื่อสั่งพลาดกลางทาง / Partial failure

ไทย — ถ้าสั่ง 2 ดวงแล้วดวงที่สองยิงไม่สำเร็จ ดวงแรกขยับไปแล้ว ห้องอยู่ในสภาพครึ่ง ๆ การโยน error เปล่า ๆ จะทำให้คนไม่รู้ว่าไฟอยู่สภาพไหน จึงออกแบบไว้ว่า:

English — if you switch 2 lights and the second RPC fails, the first light already moved and the room is half-switched. Throwing a bare error would leave the caller not knowing the physical state. So:

RPC พลาด / RPC rejects
      │
      ▼
พยายามอ่านอุปกรณ์กลับมา / attempt to read the device back
      │
      ├── อ่านได้ / read succeeds
      │      → โยน Error ที่ message มีสภาพจริง + แนบ .status (LightingStatus)
      │      → throw Error whose message states the real state, with .status attached
      │        "Command partially failed. Current state: 1 of 3 lights are on: left."
      │        error.cause = ตัว error เดิมจาก RPC / the original RPC error
      │
      └── อ่านไม่ได้ด้วย / read also fails
             → โยน Error "Command failed and device state could not be read."
               error.cause = ตัว error เดิมจาก RPC (ไม่กลืนหาย)
               error.cause = the original RPC error (never swallowed)

ไม่มีการ retry — การยิงคำสั่งซ้ำใส่อุปกรณ์ที่ไม่รู้สภาพเป็นการตัดสินใจที่ยังไม่มีใครทำ No retry — resending a hardware command to a device in an unknown state is a decision nobody has made.


5. อธิบายโค้ดทีละไฟล์ / Module-by-module walkthrough

5.1 src/config.ts — อ่านและตรวจ env

หน้าที่ / Responsibility: แปลง environment variables ดิบให้เป็น object ที่เชื่อถือได้ และตายทันทีถ้าไม่ครบ

export interface AppConfig {
  baseUrl: string;    // ตัด "/" ท้ายออกแล้ว / trailing slash stripped
  username: string;
  password: string;
  deviceId: string;
  mock: boolean;      // TB_MOCK=1 หรือ true
}

export function loadConfig(env: Record<string, string | undefined>): AppConfig

จุดออกแบบที่สำคัญ / Key design points:

  1. รับ env เป็น parameter ไม่อ่าน process.env เอง — ทำให้เทสต์ได้โดยไม่ต้องยุ่งกับ global state เทสต์ส่ง object ธรรมดาเข้าไปได้เลย Takes env as a parameter instead of reading process.env internally, so tests pass plain objects and never mutate global state.

  2. รายงานตัวแปรที่ขาดทั้งหมดใน error เดียว ไม่ใช่ตัวแรกที่เจอ ใช้ zod.safeParse แล้ว map error.issues ทั้งชุด — คนที่ตั้ง config ผิด 4 ตัวจะเห็นครบทั้ง 4 ในครั้งเดียว ไม่ต้องแก้ทีละรอบ Reports every missing variable in one error, not just the first. Someone with 4 misconfigured variables sees all 4 at once.

  3. เรียกที่ระดับ module ใน index.tsตายก่อน client ต่อเข้ามา การตั้งค่าผิดจะไม่ถูกเข้าใจผิดว่าเป็นปัญหาของอุปกรณ์ในภายหลัง Called at module level in index.ts, so a misconfiguration surfaces before any client connects and is never mistaken for a device problem later.

5.2 src/lighting.ts — ตรรกะโดเมน

ไฟล์ที่สำคัญที่สุด และเป็นไฟล์เดียวที่มีตรรกะทางธุรกิจ ไม่ import HTTP และไม่ import MCP เลย

The most important file, and the only one holding business logic. It imports neither HTTP nor MCP.

ค่าคงที่ / Constants

export type LightId = "left" | "center" | "right";
export const LIGHT_IDS: readonly LightId[] = ["left", "center", "right"];

export const RPC_METHOD: Record<LightId | "all", string> = {
  left:   "setStateLightLeft",
  center: "setStateLightCenter",
  right:  "setStateLightRight",
  all:    "setStateLightAll",
};

export const TELEMETRY_KEY: Record<LightId, string> = {
  left: "state_left", center: "state_center", right: "state_right",
};

export const TELEMETRY_KEYS: readonly string[] = [
  "state_left", "state_center", "state_right", "state_all", "any_on",
];

ชื่อ method และชื่อ key เหล่านี้เป็นชื่อจริงของ firmware ไม่ใช่ธรรมเนียมที่ปรับได้ พิมพ์ผิด 1 ตัวอักษร = สั่งไฟผิดดวงแบบเงียบ ๆ ทุกที่ในโค้ดต้องอ้างผ่านค่าคงที่พวกนี้ ห้าม hardcode string

These are the firmware's actual names, not conventions you may normalize. One typo silently switches the wrong light. All code must reference these constants rather than hardcoding strings.

หมายเหตุ: ไม่มีเลข 1/2/3 ที่ไหนในระบบเลย ตั้งใจ — ไม่มีตารางแปลเลขเป็นตำแหน่ง ก็ไม่มีโอกาสแปลผิด Note: there is deliberately no 1/2/3 numbering anywhere. No mapping table means no mis-mapped light.

parseTbBoolean(raw: unknown, key: string): boolean

ฟังก์ชันเล็กที่สุดแต่สำคัญที่สุดในโปรเจกต์ / The smallest and most important function here.

รับได้ / Accepts

true / false (boolean จริง — mock ใช้)

ตามค่า

1 / 0 (number)

true / false

"1" / "0"

true / false

"true" / "false"

true / false

"ON" / "OFF" ← อุปกรณ์จริงส่งค่านี้

true / false

เทียบแบบ trim() + toLowerCase() แล้ว เทียบเท่ากับสตริงทั้งตัว (v === "on") ไม่ใช่ startsWith — ดังนั้น "ONLINE" ถูกปฏิเสธ

Comparison is trim() + toLowerCase() then whole-string equality (v === "on"), not startsWith — so "ONLINE" is rejected.

ค่าที่ไม่รู้จัก → throw พร้อมระบุชื่อ key ห้าม return false

throw new Error(
  `Cannot interpret telemetry key "${key}" as a boolean. ` +
  `Got ${JSON.stringify(raw)}. Refusing to assume the light is off.`
);

ทำไมเรื่องนี้สำคัญมาก / Why this matters so much:

spec ฉบับแรกเดารูปแบบค่านี้ไว้ 3 ทาง — "true"/"false", "1"/"0", boolean จริง — และ ผิดทั้งสามทาง อุปกรณ์จริงส่ง "ON" ตอนต่อของจริงครั้งแรก parser ปฏิเสธเสียงดังทันที เราจึงรู้ตัว

ถ้าตอนนั้นเขียน else return false ตามที่คนทำกันทั่วไป server จะตอบว่า "ไฟเปิด 0 ดวง" อย่างมั่นใจในขณะที่ไฟเปิดอยู่จริงทั้ง 3 ดวง แล้วคนก็จะเชื่อและตัดสินใจจากข้อมูลนั้น

The original spec guessed this format three ways and was wrong all three times. The real device sends "ON". The parser refused loudly, which is the only reason we found out. Had it defaulted to false, the server would have confidently reported "0 of 3 lights are on" while all three were lit — and a human would have acted on that.

อย่าลบ throw นี้ทิ้งเพื่อความสะดวก / Do not remove this throw for convenience.

readLighting(client): Promise<LightingStatus>

export interface LightingStatus {
  onCount: number;                          // 0-3 นับจาก 3 key เดี่ยวเท่านั้น
  total: number;                            // = LIGHT_IDS.length (ไม่ hardcode 3)
  lights: Record<LightId, boolean>;          // { left, center, right }
  reported: { state_all: boolean; any_on: boolean };  // ค่าสรุปจากอุปกรณ์
  consistent: boolean;                       // false = ข้อมูลขัดแย้งกัน
  warnings: string[];
}

กฎ 2 ข้อ / Two rules:

กฎที่ 1 — นับจาก 3 key เดี่ยวเท่านั้น อุปกรณ์ส่งค่าสรุปมาให้ 2 ตัว (state_all, any_on) แต่ห้ามใช้นับ ใช้ cross-check อย่างเดียว

Rule 1 — count from the 3 individual keys only. The device provides 2 summary keys, but they must never influence the count; they are for cross-checking.

กฎที่ 2 — ขัดแย้งต้องรายงาน ห้ามกลบ ถ้า 3 key เดี่ยวบอกว่าปิดหมดแต่ any_on=true แสดงว่าข้อมูลหรืออุปกรณ์มีปัญหา ต้องบอกออกไปพร้อมค่าทั้ง 5 ตัว

"0 of 3 lights are on. WARNING: Device reports any_on=true but the
 individual keys give 0/3 on (expected any_on=false)."

Rule 2 — contradictions get reported, never smoothed over. Reporting a plausible-looking number over a contradiction is worse than saying the data cannot be trusted, because Claude relays that number to a human.

key ที่หายไปจาก response หรือค่าที่แปลไม่ได้ → throw เสมอ ไม่ตีความว่าปิด A key missing from the response, or an uninterpretable value, always throws. Never treated as off.

setLighting(client, lights, on): Promise<LightingStatus>

ทางเขียน ทางเดียว ของทั้งระบบ ออกแบบให้เป็นฟังก์ชันเดียวเพื่อให้มีจุดเดียวที่ต้องตรวจสอบเรื่องความปลอดภัย

The only write path in the system. Deliberately one function, so there is exactly one place to audit for safety.

// ตรวจก่อน ไม่มี RPC ถูกส่ง / validate first, no RPC sent
if (lights.length === 0) throw new Error("Specify at least one light: left, center, or right.");
if (new Set(lights).size !== lights.length) throw new Error(`Duplicate light in request: ...`);

// ครบ 3 ดวง → ยิงครั้งเดียว / all three → one call
if (unique.size === LIGHT_IDS.length) {
  await client.sendOneWayRpc(RPC_METHOD.all, on);
} else {
  // วน LIGHT_IDS ไม่ใช่ lights ที่ caller ส่งมา → ลำดับคงที่เสมอ
  // iterate LIGHT_IDS, not the caller's array → deterministic order always
  for (const id of LIGHT_IDS) if (unique.has(id)) await client.sendOneWayRpc(RPC_METHOD[id], on);
}

return readLighting(client);   // ★ อ่านกลับ ไม่เชื่อว่าสั่งแล้วสำเร็จ

3 เรื่องที่ตั้งใจออกแบบ / Three deliberate behaviours:

  1. ครบ 3 ดวง = ยิง setStateLightAll ครั้งเดียว "เปิดไฟทั้งห้อง" คือคำสั่งที่ใช้บ่อยที่สุด ยิง 3 ครั้งที่ยิงครั้งเดียวก็ได้ = เพิ่มโอกาสพลาดกลางทางเป็น 3 เท่า All three collapses to one call. Three round-trips where one suffices means three chances of a partial failure.

  2. ลำดับที่ caller ส่งมาไม่มีผล ["right","left","center"] = ["left","center","right"] เพราะวน LIGHT_IDS Caller order is irrelevant because dispatch iterates LIGHT_IDS.

  3. คืนค่าที่อ่านได้จริงหลังยิง ไม่ใช่ echo ค่าที่สั่ง — เพราะ one-way RPC ไม่ยืนยันว่าอุปกรณ์ทำจริง Returns a freshly-read status, not an echo of the request, because one-way RPC confirms nothing.

describeLighting(status): string

แปลง LightingStatus เป็นประโยคที่ Claude อ่าน ถ้าข้อมูลขัดแย้ง คำเตือนต้องอยู่ในประโยคนี้ด้วย ไม่ใช่ซ่อนอยู่แค่ใน field — ไม่งั้น Claude อาจรายงานตัวเลขที่ดูสะอาดโดยไม่เอ่ยถึงความขัดแย้งเลย

Turns LightingStatus into the sentence Claude reads. When data is inconsistent the warning must appear in this sentence, not only in a structured field — otherwise Claude could report a clean-sounding number while the contradiction goes unmentioned.

5.3 src/tb-client.ts — ชั้น HTTP

ชั้นเดียวที่รู้จัก URL, JWT และ header ถ้าย้าย instance หรือเปลี่ยนไปใช้ RPC v2 แก้ไฟล์นี้ไฟล์เดียว

The only layer that knows URLs, JWT, or headers. Moving instances or switching to RPC v2 touches this file alone.

export class ThingsBoardClient implements LightingClient {
  constructor(config: AppConfig, fetchImpl: typeof fetch = fetch) {}
}

fetchImpl ที่ inject ได้คือเหตุที่เทสต์ทั้ง 9 ตัวรันได้โดยไม่แตะเน็ตจริงเลย The injectable fetchImpl is why all 9 of its tests run with no real network.

4 รายละเอียดที่พลาดง่ายและแพง / Four details that are easy and expensive to get wrong

1. header คือ X-Authorization ไม่ใช่ Authorization

"X-Authorization": `Bearer ${this.token}`

ยืนยันจาก security scheme ของ instance เอง: bearerFormat: "/api/auth/login|X-Authorization" ถ้าส่ง Authorization จะได้ 401 ตลอดกาลแบบไม่มีคำอธิบาย มีเทสต์ assert ว่า Authorization เป็น undefined

Verified from the instance's own security scheme. Sending Authorization yields silent perpetual 401. A test asserts Authorization is undefined.

2. RPC path เป็นรุ่น v1 — มี plugins

POST /api/plugins/rpc/oneway/{deviceId}      ← ที่ใช้ / what we use (tag rpc-v-1-controller)
POST /api/rpc/oneway/{deviceId}              ← มีอยู่ด้วย ไม่ deprecated (tag rpc-v-2-controller)

ทั้งสองใช้ได้และไม่มีตัวไหน deprecated เลือก v1 เพราะ frontend เดิมของทีมใช้ v1 — repo เดียวกันควรเรียกทางเดียวกัน อย่า "ปรับให้ทันสมัย" เป็น v2

Both work and neither is deprecated. v1 was chosen because the team's existing frontend uses v1. Do not "modernize" it.

*3. getTimeseries ต้องแบนข้อมูลและห้ามแปลงชนิด*

ThingsBoard คืน:

{ "state_left": [ {"ts": 1786343456770, "value": "ON"} ] }

ต้องแปลงเป็น { state_left: "ON" } โดย:

  • เลือก entry ที่ ts มากที่สุด ไม่ใช่ index 0 (entries.reduce((a,b) => b.ts >= a.ts ? b : a))

  • key ที่ array ว่าง → ข้ามไปเลย ไม่ใส่เป็น undefined

  • ส่งค่าดิบออกไปตรง ๆ return type เป็น Record<string, unknown> โดยเจตนา การตีความเป็นหน้าที่ของ parseTbBoolean เท่านั้น ถ้าชั้นนี้แปลงเป็น boolean เอง จะทำลายความสามารถในการปฏิเสธค่าที่ไม่รู้จัก

Picks the entry with the greatest ts, skips keys with empty arrays, and passes the raw value through untouched. The unknown return type is deliberate: interpretation belongs to parseTbBoolean alone.

4. login แบบ lazy + 401 = login ใหม่ 1 ครั้ง ลองซ้ำ 1 ครั้ง ไม่วนลูป

เรียกครั้งแรก / first call → ยังไม่มี token → login → ยิง request
401 กลางทาง / mid-session 401 → ทิ้ง token → login ใหม่ → ยิงซ้ำ 1 ครั้ง
401 อีกครั้ง / 401 again → ยอมแพ้ + บอกว่า credential ใช้ไม่ได้กับงานนี้

รวมสูงสุด 2 login + 2 request ต่อการเรียก 1 ครั้ง constructor ไม่ยิง network เลย (มีเทสต์ assert ว่า 0 request หลังสร้าง object)

At most 2 logins and 2 requests per call. The constructor performs zero network I/O.

ข้อความ error เป็นส่วนหนึ่งของ product / Error messages are a product surface

Claude อ่านข้อความพวกนี้แล้วเล่าให้คนที่ต้องไปแก้ปัญหาฟัง จึงต้องบอกว่าให้ไปดูที่ไหน:

Claude relays these to a human who has to fix the problem, so each must say what to check:

สถานการณ์

ข้อความ

401 ตอน login

ThingsBoard rejected the credential in TB_USERNAME / TB_PASSWORD. Check your .env file.

401 สองครั้ง

ThingsBoard returned 401 twice. The credential ... is not valid for this operation.

404

ThingsBoard has no device with id "<id>". Check TB_LIGHTING_DEVICE_ID.

ต่อไม่ได้

Cannot reach ThingsBoard at <baseUrl>. It may be unreachable from this machine. + cause

ห้ามใส่ password หรือ token ลงใน error message เด็ดขาด Never place the password or token in any error message.

5.4 src/mock.ts — อุปกรณ์ปลอม

implement LightingClient เหมือนกันเป๊ะ ใช้ตอนเทสต์ และตอน TB_MOCK=1

new MockLightingClient({
  initial?:   { left: true },              // เริ่มด้วยไฟที่เปิดอยู่
  overrides?: { any_on: true },            // บังคับค่า key ให้ชนะค่าที่คำนวณได้
  omitKeys?:  ["state_right"],             // ตัด key ออกจาก response
})
mock.state   // { left, center, right }
mock.calls   // [{ method, params }, ...] ทุก RPC เรียงตามลำดับ ไม่ dedup

ความซื่อสัตย์ของ mock สำคัญมาก เพราะเทสต์ของ readLighting/setLighting พิสูจน์ความถูกต้องบนมันทั้งหมด ถ้า mock โกหก เทสต์จะผ่านโดยพิสูจน์เรื่องที่ผิด ดังนั้น:

Mock fidelity is critical: the correctness proofs for readLighting/setLighting all rest on it. If the mock lies, those tests pass while proving the wrong thing. Therefore:

  • state_all / any_on คำนวณสดทุกครั้งที่อ่าน ไม่เก็บไว้ (ไม่มีโอกาส drift) derived fresh on every read, never stored

  • overrides มีไว้เพื่อสร้างสถานการณ์ที่อุปกรณ์ขัดแย้งกับตัวเอง สำหรับทดสอบ cross-check exists to manufacture self-contradicting device data for cross-check tests

  • calls บันทึก RPC ทุกตัวรวมทั้งตัวที่ผิด ไม่ dedup ไม่เรียงใหม่ — เพราะเทสต์ต้องพิสูจน์ว่า "ครบ 3 ดวงยิงครั้งเดียว" ด้วย calls.length records every RPC including invalid ones, so calls.length can prove the one-call-not-three behaviour

  • method ที่ไม่รู้จัก → throw (firmware จริงก็ไม่รับ) unknown method throws, as the real firmware would

5.5 src/index.ts — MCP wiring

ชั้นบางที่สุด ไม่มีตรรกะธุรกิจเลย / The thinnest layer, zero business logic.

const config = loadConfig(process.env);              // ตายก่อน client ต่อ / fail fast
const client = config.mock ? new MockLightingClient() : new ThingsBoardClient(config);

const server = new McpServer({ name: "thingsboard-lighting", version: "1.0.0" });
server.registerTool("get_lighting", {...}, handler);
server.registerTool("set_lighting", {...}, handler);
await server.connect(new StdioServerTransport());

★ stdout เป็นช่อง JSON-RPC — ห้ามเขียนอะไรลงไป

console.log ตัวเดียวทำโปรโตคอลเสีย และอาการที่เห็นจะเหมือน client เป็นฝ่ายผิด ทุกข้อความ diagnostic ต้องไป console.error (stderr) รวมทั้งบรรทัดแจ้งว่า server ขึ้นแล้วและบรรทัดแจ้ง TB_MOCK

A single console.log corrupts the protocol and presents as a client bug. Every diagnostic goes to console.error.

มีเทสต์คุมเรื่องนี้ (test/index.handshake.test.ts) ที่ตรวจ ทุกบรรทัด ของ stdout ว่า parse เป็น JSON-RPC ได้ บรรทัดที่ parse ไม่ได้ = เทสต์ fail (เคยเขียนผิดเป็น filter ทิ้งบรรทัดที่ไม่ขึ้นต้นด้วย { ซึ่งทำให้เทสต์ไม่มีทาง fail เลย — แก้แล้วและพิสูจน์ด้วยการแอบใส่ console.log ให้เทสต์ fail จริงก่อน)

A test guards this by checking every stdout line parses as JSON-RPC; a non-parsing line fails the test. (It was originally written to filter out lines not starting with {, which made it unable to fail at all. Fixed, and proven by deliberately injecting a console.log and watching it fail.)

ด่าน zod คือรั้วจริง / The zod enum is the real fence

inputSchema: z.object({
  lights: z.array(z.enum(["left", "center", "right"])).min(1),
  on: z.boolean(),
})

setLighting เชื่อ type readonly LightId[] แค่ตอน compile — string แปลก ๆ ที่หลุดมาจาก JSON จะถูกทิ้งเงียบ ๆ ไม่ได้ถูกปฏิเสธ ด่าน zod ที่นี่คือสิ่งที่ทำให้เป็นไปไม่ได้ ห้ามลดเป็น z.string()

ทดลองแล้ว: ส่ง lights: ["ceiling"] → ได้

Input validation error: Invalid arguments for tool set_lighting:
lights.0: Invalid option: expected one of "left"|"center"|"right"

setLighting trusts its type at compile time only; a stray string from JSON would be silently dropped rather than rejected. This enum is what makes that impossible. Do not weaken it to z.string().

annotations เป็นป้ายบอก ไม่ใช่รั้ว / Annotations are labels, not fences

MCP spec เขียนว่า clients MUST consider tool annotations to be untrusted ดังนั้นค่าเหล่านี้บอกเจตนาให้ UI ของ client รู้ ไม่ได้บังคับอะไรเลย:

tool

annotations

ทำไม / why

get_lighting

readOnlyHint: true, openWorldHint: true

อ่านเท่านั้น, แตะระบบภายนอก

set_lighting

readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true

destructiveHint: false เพราะเปิด/ปิดไฟย้อนกลับได้ · idempotentHint: true เพราะสั่งเปิดซ้ำผลเท่าเดิม

รั้วจริงคือ: server นี้มี tool แค่ 2 ตัว ผูก device id เดียวจาก config และ ไม่มี generic RPC passthrough (ตอนออกแบบมีคนเสนอ thingsboard_rpc(deviceId, method, params) และถูกปฏิเสธเพราะไม่มีขอบเขต)

The real fence: exactly 2 narrow tools against one fixed device id, and no generic RPC passthrough (proposed during design and rejected as unbounded).

ok() / fail()

function ok(status)   { return { content: [{type:"text", text: describeLighting(status)}],
                                 structuredContent: status }; }
function fail(error)  { return { content: [{type:"text", text: message}], isError: true }; }

isError: true คือ tool execution error ตาม MCP spec — spec แนะให้ client ส่งต่อให้ model เพื่อให้แก้ตัวเองได้ ไม่ปล่อย stack trace ออกไป และไม่ทำให้ server ตาย

isError: true is a tool execution error; the spec says clients SHOULD feed these to the model so it can self-correct. No stack trace escapes and the server never crashes.


6. สัญญาของ MCP tools / MCP tool contracts

get_lighting

// input — ไม่รับ argument / takes no arguments
{}

// output
{
  "content": [{ "type": "text", "text": "3 of 3 lights are on: left, center, right." }],
  "structuredContent": {
    "onCount": 3,                 // นับจาก 3 key เดี่ยว / from the 3 individual keys
    "total": 3,
    "lights":   { "left": true, "center": true, "right": true },
    "reported": { "state_all": true, "any_on": true },   // ค่าสรุปที่อุปกรณ์รายงาน
    "consistent": true,           // false = ขัดแย้งกัน / contradictory
    "warnings": []
  }
}

set_lighting

// input
{
  "lights": ["left"],   // 1-3 ตัว จาก "left" | "center" | "right" ห้ามซ้ำ ห้ามว่าง
  "on": false           // true = เปิด / false = ปิด
}

// output — structuredContent เหมือน get_lighting แต่เป็นค่าที่อ่านได้ *หลัง* สั่ง
// same shape as get_lighting, but read AFTER the command
{
  "content": [{ "type": "text", "text": "2 of 3 lights are on: center, right." }],
  "structuredContent": { "onCount": 2, ... }
}

7. ข้อมูลอ้างอิงของ ThingsBoard / ThingsBoard reference

ทุกค่าในหัวข้อนี้ยืนยันจาก /v3/api-docs ของ instance จริง หรือจากการอ่าน/เขียนของจริง — ไม่ใช่จากความจำ

Every value here was verified against the live instance's own OpenAPI document or by real reads/writes.

รายการ

ค่า

Instance

https://thingsboard.syntechnology.com (self-hosted, ไม่ใช่ thingsboard.cloud)

REST API version

3.9.0

Auth header

X-Authorization: Bearer <jwt>

Login

POST /api/auth/login{ token, refreshToken }

Telemetry (ล่าสุด)

GET /api/plugins/telemetry/DEVICE/{deviceId}/values/timeseries?keys=...

Telemetry (ประวัติ)

เพิ่ม &startTs=<ms>&endTs=<ms>&limit=<n>

RPC

POST /api/plugins/rpc/oneway/{deviceId}

RPC body

{ "method": "setStateLightLeft", "params": true }

หมายเหตุเรื่อง startTs/endTs: OpenAPI ของ instance นี้ mark ทั้งสองเป็น required บน path ของ latest-value ด้วย แต่ใช้งานได้จริงโดยไม่ต้องส่ง สาเหตุคือ Spring ผูก handler สองตัวไว้ path เดียวกัน แล้ว generator รวมเป็น operation เดียว ยืนยันด้วยการยิงจริงแล้ว

The instance's OpenAPI marks both as required on the latest-values path, but it works without them. That is an artifact of two Spring handlers sharing one path being merged into a single operation. Verified empirically.

telemetry keys ของอุปกรณ์นี้ / this device's telemetry keys

key

ความหมาย

ใช้ทำอะไร

state_left

ไฟดวงซ้าย

นับ / counted

state_center

ไฟดวงกลาง

นับ / counted

state_right

ไฟดวงขวา

นับ / counted

state_all

ไฟครบ 3 ดวงหรือไม่

cross-check เท่านั้น / cross-check only

any_on

มีไฟเปิดอย่างน้อย 1 ดวงหรือไม่

cross-check เท่านั้น / cross-check only

ค่าที่อุปกรณ์ส่ง: "ON" / "OFF" (string) เท่านั้น สังเกตจากสายจริงทั้งสองค่าเมื่อ 2026-08-10 Values sent by the device: the strings "ON" and "OFF" only. Both observed on the wire.

enum BaseLighting ใน frontend เดิมมี key ชื่อ state ด้วย — ไม่ใช้ในงานนี้ และอุปกรณ์จริงก็ไม่ได้ส่ง key นั้นมาเลย (ตรวจด้วย GET .../keys/timeseries แล้ว) The frontend's enum BaseLighting also lists a state key. It is out of scope here, and the real device does not report it at all.


8. การตั้งค่า / Configuration

TB_BASE_URL=https://thingsboard.syntechnology.com
TB_USERNAME=<ThingsBoard user>
TB_PASSWORD=<ThingsBoard password>
TB_LIGHTING_DEVICE_ID=<UUID ของ device ห้อง innovation>
TB_MOCK=0                # 1 = ใช้อุปกรณ์ปลอม ไฟจริงไม่ขยับ / use the mock, no real lights move

กฎเรื่องความปลอดภัย / Security rules:

  • .env อยู่ใน .gitignore แล้ว — ห้าม commit

  • .env.example เก็บชื่อ key เท่านั้น ไม่มีค่า อย่าเผลอกรอกค่าจริงลงไปเพราะไฟล์นี้ git ติดตามอยู่ (.gitignore มี !.env.example ยกเว้นไว้)

  • ตรวจก่อน commit ทุกครั้ง: git check-ignore -v .env ต้องมี output

  • .env is gitignored — never commit it.

  • .env.example holds key names only. Do not fill in real values there: that file IS tracked by git (.gitignore has !.env.example).

หา TB_LIGHTING_DEVICE_ID / Finding the device id

TOKEN=$(curl -s -X POST https://thingsboard.syntechnology.com/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"YOUR_USER","password":"YOUR_PASS"}' \
  | python -c "import json,sys; print(json.load(sys.stdin)['token'])")

curl -s "https://thingsboard.syntechnology.com/api/tenant/devices?pageSize=100&page=0" \
  -H "X-Authorization: Bearer $TOKEN" \
  | python -c "import json,sys; [print(d['id']['id'], d['name']) for d in json.load(sys.stdin)['data']]"

9. ต่อกับ Claude Desktop / Connecting Claude Desktop

⚠️ หา config ไฟล์ให้ถูกก่อน / Find the right config file first

ไทย — ถ้า Claude Desktop ลงมาจาก Microsoft Store (MSIX) AppData ของมันถูก virtualize ทำให้ ไม่ได้อ่าน %APPDATA%\Claude\ ตามที่เอกสารทั่วไปบอก ไฟล์จริงอยู่ที่:

%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude_desktop_config.json

เครื่องนี้คือ Claude_pzs8sxrjxfjjc

การแก้ไฟล์ path ที่เอกสารบอกจะดู "สำเร็จ" แต่ไม่มีผลอะไรเลย วิธีจับว่าแก้ผิดไฟล์: ดู prefix ใน log ของ Claude Desktop มันจะเป็นชื่อ server จาก config ที่มันอ่านจริง ถ้าไม่ตรงกับชื่อที่เพิ่งเขียนไป = ผิดไฟล์

English — if Claude Desktop came from the Microsoft Store (MSIX) build, its AppData is virtualized and it does not read %APPDATA%\Claude\. The real file is under %LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\. Editing the documented path appears to succeed and changes nothing. The tell: Claude Desktop's log prefixes each line with the server name from the file it actually read — a mismatch means you edited the wrong file.

รูปแบบ config / Config shape

{
  "mcpServers": {
    "lighting": {
      "command": "node",
      "args": ["C:\\Users\\<you>\\mcp-server-test\\build\\src\\index.js"],
      "env": {
        "TB_BASE_URL": "https://thingsboard.syntechnology.com",
        "TB_USERNAME": "...",
        "TB_PASSWORD": "...",
        "TB_LIGHTING_DEVICE_ID": "...",
        "TB_MOCK": "0"
      }
    }
  }
}

3 เรื่องที่ต้องระวัง / Three things to watch:

  1. path ต้องเป็น build\src\index.js ไม่ใช่ build\index.js (ดูหัวข้อ 3)

  2. Claude Desktop ไม่อ่าน .env ตัวแปรต้องอยู่ใน env block นี้เท่านั้น → รหัสจะอยู่ในไฟล์นี้แบบ plaintext ซึ่งเป็นทางเดียวที่ทำได้ Claude Desktop does not read .env; variables must live in this env block, which means the password sits here in plaintext. That is the only supported way.

  3. ต้อง ปิด Claude Desktop ให้สุด (tray icon → Quit) ปิดแค่หน้าต่างไม่พอ process ยังอยู่และจะไม่อ่าน config ใหม่ Fully quit Claude Desktop from the tray. Closing the window leaves the process running and it will not reread the config.

⚠️ bypassPermissionsModeEnabled

ถ้า preferences.bypassPermissionsModeEnabled เป็น true Claude Desktop จะไม่ถามก่อนเรียก tool พูดว่า "ปิดไฟ" แล้วไฟดับทันทีโดยไม่มีกล่องยืนยัน ตรวจค่านี้ก่อนถ้าคิดว่ามีกล่องขออนุญาตคอยกันอยู่

If preferences.bypassPermissionsModeEnabled is true, Claude Desktop does not prompt before tool calls. Saying "turn off the light" switches it immediately. Check this before assuming a confirmation dialog protects you.


10. การทดสอบ / Testing

npm test          # tsc && node --test "build/test/**/*.test.js"   → 57 tests

glob ใน test script จำเป็น อย่าลดเป็น node --test build/ — บน Node 24 + Windows รูปแบบนั้นไม่ทำ test-file discovery แต่จะโหลด build/src/index.js ซึ่งเป็น stdio server ที่รอ stdin ตลอดกาล แล้วคำสั่งจะค้างโดยไม่มี output เลย (พิสูจน์แล้ว)

The glob is load-bearing. Do not reduce it to node --test build/: on Node 24 + Windows that form does not perform test-file discovery — it loads the stdio server, which waits on stdin forever, and the command hangs with no output.

ไฟล์

ทดสอบอะไร

config.test.ts

env ที่ถูก, ตัด slash ท้าย, รายงานตัวแปรที่ขาดทั้งหมดใน error เดียว

lighting.parse.test.ts

รับ ON/OFF/true/1/boolean, ปฏิเสธ ONLINE/""/null/2 พร้อมระบุ key

lighting.read.test.ts

นับถูกครบ 8 combination, จับข้อมูลขัดแย้ง, key หาย = throw

lighting.set.test.ts

ครบ 3 ดวง = 1 call, ลำดับไม่มีผล, validation ก่อนยิง, partial failure

mock.test.ts

mock คำนวณ state_all/any_on สด, calls บันทึกครบ

tb-client.test.ts

X-Authorization, URL/body, flatten by max ts, 401 retry-once, error mapping

index.handshake.test.ts

spawn server จริง ยิง JSON-RPC จริง ตรวจ tool + annotations + stdout purity

เทสต์ทั้งหมด ไม่แตะเน็ตและไม่แตะไฟจริงtb-client ใช้ stub fetch, handshake test ใช้ TB_MOCK=1 No test touches the network or real lights: tb-client uses a stub fetch, and the handshake test runs with TB_MOCK=1.

ทดสอบกับของจริงด้วยมือ / Manual real-device check

# อ่านเท่านั้น ปลอดภัย / read-only, safe
printf '%s\n%s\n%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_lighting","arguments":{}}}' \
 | node --env-file=.env build/src/index.js

11. แก้ปัญหา / Troubleshooting

อาการ / Symptom

สาเหตุ / Cause

แก้ / Fix

Invalid environment configuration: TB_BASE_URL: ... ตอนรันเปล่า ๆ

ไม่มีอะไรโหลด .env

node --env-file=.env build/src/index.js หรือ npm start

Claude Desktop: Server transport closed unexpectedly ทันทีที่ start

env block ขาดใน config → server ตายตอน loadConfig

ใส่ env block ให้ครบ (หัวข้อ 9)

แก้ config แล้วไม่มีอะไรเปลี่ยน

แก้ผิดไฟล์ (MSIX ใช้ path อื่น)

ดูหัวข้อ 9 · เทียบชื่อ server ใน log

Cannot interpret telemetry key "state_left" as a boolean. Got "XYZ"

firmware ส่งค่ารูปแบบใหม่

นี่คือระบบทำงานถูก — เพิ่มรูปแบบใน parseTbBoolean + เทสต์ ห้ามเปลี่ยนเป็น return false

ThingsBoard rejected the credential in TB_USERNAME / TB_PASSWORD

รหัสผิด/หมดอายุ

แก้ .env และ env block ใน Claude Desktop config

ThingsBoard has no device with id "..."

TB_LIGHTING_DEVICE_ID ผิด

หา id ใหม่ (หัวข้อ 8)

สั่งไฟแล้วรายงานว่าไฟไม่ขยับ

one-way RPC ส่งออกแล้วแต่อุปกรณ์ไม่ทำ (อาจ offline)

เช็คสถานะ device ใน ThingsBoard — server รายงานตามที่อ่านได้จริง ไม่ได้โกหก

npm test ค้างไม่มี output

test script ถูกลดเป็น node --test build/

คืน glob "build/test/**/*.test.js"


12. ต่อยอดอย่างไร / How to extend

เพิ่มไฟดวงที่ 4 / Add a fourth light

  1. src/lighting.ts — เพิ่มใน LightId, LIGHT_IDS, RPC_METHOD, TELEMETRY_KEY, TELEMETRY_KEYS

  2. src/index.ts — เพิ่มค่าใน z.enum([...]) และใน StatusShape.lights

  3. src/mock.tsstate เริ่มต้นและ derived map

  4. เทสต์ — loop combination จะกลายเป็น 2⁴ = 16 กรณี, total เปลี่ยนเองเพราะใช้ LIGHT_IDS.length

onCount และ total ไม่ต้องแก้ เพราะไม่ได้ hardcode เลข 3 ไว้ onCount and total need no change because 3 is never hardcoded.

เพิ่มห้องอื่น / Add another room

ตอนนี้ device id ถูก fix ไว้ตัวเดียวใน env — ตั้งใจ เพื่อจำกัดขอบเขต ถ้าจะรับหลายห้อง:

The device id is deliberately fixed to one value in env. To support multiple rooms:

  1. เปลี่ยน config เป็น map: { innovation: "<uuid>", meeting: "<uuid>" }

  2. เพิ่ม parameter room ใน tool ทั้งสองตัว เป็น z.enum([...]) ของชื่อห้องที่รู้จักเท่านั้น ห้ามเป็น z.string() และห้ามรับ device id ดิบจาก Claude — ไม่งั้นขอบเขตหลุดทันที add a room parameter as a z.enum of known room names — never z.string(), and never accept a raw device id from Claude

  3. ThingsBoardClient ต้องรับ deviceId ต่อ call แทนที่จะผูกไว้ใน constructor

เพิ่มอุปกรณ์ประเภทอื่น (แอร์, ม่าน) / Other equipment types

สร้าง module โดเมนใหม่ (เช่น src/hvac.ts) แบบเดียวกับ lighting.ts แล้วใช้ LightingClient ซ้ำ (อาจ rename เป็น ThingsBoardDeviceClient) tb-client.ts ไม่ต้องแก้เลยเพราะมันไม่รู้จักไฟอยู่แล้ว

Create a new domain module alongside lighting.ts and reuse the client interface. tb-client.ts needs no changes — it knows nothing about lights.

ยังคงห้ามทำ generic passthroughthingsboard_rpc(deviceId, method, params) ถูกพิจารณาและปฏิเสธตอนออกแบบ เพราะทำให้ Claude ยิง method อะไรใส่อุปกรณ์ไหนก็ได้ ไม่มีขอบเขตให้ตรวจ

Still no generic passthrough: thingsboard_rpc(deviceId, method, params) was considered and rejected because it lets Claude send any method to any device, with no boundary to audit.

เพิ่มโหมดอ่านอย่างเดียว / Add a read-only mode

ยังไม่มี ถ้าต้องการ: เพิ่ม TB_READ_ONLY ใน config.ts แล้วใน index.ts ไม่ register set_lighting เลย เมื่อเปิดโหมดนี้ — แข็งแรงกว่าให้ handler ปฏิเสธ เพราะไม่มี code path ให้พลาด

Not implemented. If wanted: add TB_READ_ONLY to config.ts and simply do not register set_lighting when set — stronger than a refusing handler because there is no code path to get wrong.


13. เหตุผลการออกแบบ / Design decisions

เรื่อง

เลือก

เหตุผล

Tools vs Resources

Tools ทั้งหมด

ทั้ง 3 ประโยคเป็นงานที่ model ต้องตัดสินใจเรียกเอง (model-controlled) ส่วน Resources เป็น application-controlled

จำนวน tool

2 ตัว

ทางเขียนมีทางเดียว → จุดที่ต้องตรวจเรื่องความปลอดภัยมีจุดเดียว ทางเลือก 3 tool (แยก on/off) ทำให้โค้ดซ้ำและมีสองทางต้องเฝ้า

generic RPC passthrough

ปฏิเสธ

ไม่มีขอบเขต Claude ยิง method อะไรใส่ device ไหนก็ได้

ชื่อดวงไฟ

left/center/right ไม่ใช่ 1/2/3

ไม่มีตารางแปล = ไม่มีโอกาสแปลผิด สั่งไฟผิดดวงเป็น error ที่ผู้ใช้เห็นทันทีและกวนใจ

RPC v1 vs v2

v1 (/api/plugins/rpc/...)

ไม่ deprecated และ frontend เดิมของทีมใช้ v1 อยู่แล้ว

oneway vs twoway

oneway + อ่าน telemetry ทานซ้ำ

ตรงกับ frontend เดิม และการทานด้วย telemetry เชื่อถือได้จริงกว่าค่า reply จาก twoway

ค่าที่แปลไม่ได้

throw ไม่ default เป็น false

รายงาน "ไฟปิด" ผิดอย่างมั่นใจแย่กว่ารายงานว่าอ่านไม่ได้ เพราะคนจะตัดสินใจจากมัน

นับดวงจาก key ไหน

3 key เดี่ยวเท่านั้น

state_all/any_on เป็นค่าที่อุปกรณ์คำนวณเอง ใช้ cross-check ดีกว่าใช้เป็นแหล่งความจริง

บทเรียนที่จ่ายค่าเรียนไปแล้ว / Lessons already paid for

  1. การเดารูปแบบข้อมูลแพงกว่าที่คิด spec เดารูปแบบค่า telemetry ไว้ 3 ทาง ผิดทั้ง 3 ทาง อุปกรณ์จริงส่ง "ON" สิ่งเดียวที่ช่วยไว้คือ parser ที่ปฏิเสธค่าที่ไม่รู้จักแทนที่จะ default

  2. เทสต์ที่ไม่เคยเห็นมันพัง ยังไม่นับเป็นเทสต์ เทสต์ที่คุม stdout เคยเขียนเป็น filter(l => l.startsWith("{")) ซึ่งทิ้ง console.log ที่หลุดมาทั้งบรรทัด ทำให้เทสต์ไม่มีทาง fail เลย รู้ตัวตอนตั้งใจแอบใส่ console.log เพื่อดูว่ามัน fail จริงไหม

  3. ผ่านเทสต์ ≠ ถูก โค้ดจัดการ partial failure รอบแรกผ่าน 39 เทสต์ และผ่านการ probe ด้วยมือ แต่มันแยก "อ่านพลาด" กับ "ตั้งใจ throw" ด้วยการดมว่า error มี property status ไหม — ซึ่งจะระเบิดทันทีที่ HTTP client เข้ามา (error ของ HTTP มี .status เป็นเลข status code) แก้ด้วยการวางขอบเขต try/catch ให้ถูกจนไม่ต้องดมอะไรเลย

  4. chmod 755 ใน build script คือกับดักข้าม platform มาจาก quickstart ของ MCP ที่เขียนบน macOS/Linux บน Windows npm run build จะพังที่ 'chmod' is not recognized โดยที่ tsc สำเร็จไปแล้ว


14. ข้อจำกัดที่รู้อยู่ / Known gaps

เรื่อง

สถานะ

ไม่มีอะไรโหลด .env

ตั้งใจไม่เพิ่ม dependency ใช้ node --env-file=.env ของ Node 24 แทน

ไม่มีโหมดอ่านอย่างเดียว

set_lighting เปิดใช้ได้เสมอ ดูวิธีเพิ่มในหัวข้อ 12

ผูก device เดียว

ตั้งใจ เพื่อจำกัดขอบเขต ดูวิธีขยายในหัวข้อ 12

ไม่มี lock ตอน refresh token

ถ้ามี 2 request ชนกันตอน token ว่าง จะ login ซ้ำซ้อน 1 ครั้ง ไม่มี request ไหน fail — ยอมรับได้สำหรับ stdio server ผู้ใช้เดียว

set_lighting ไม่ retry

ตั้งใจ การยิงซ้ำใส่อุปกรณ์ที่ไม่รู้สภาพเป็นการตัดสินใจที่ยังไม่มีใครทำ

ไม่มี rate limit ฝั่ง server

ThingsBoard มีของตัวเอง ยังไม่เจอปัญหาในการใช้งานจริง


เอกสารเพิ่มเติม / Further reading

  • docs/superpowers/specs/2026-08-10-thingsboard-lighting-mcp-design.md — เอกสารออกแบบฉบับเต็ม รวมผลการยืนยันกับของจริงและข้อควรระวังเรื่องสภาพแวดล้อม

  • docs/superpowers/plans/2026-08-10-thingsboard-lighting-mcp.md — แผน implement ทีละ task พร้อมโค้ดและเทสต์เต็ม ใช้เป็นตัวอย่างวิธีเพิ่มงานใหม่ได้

  • MCP server concepts · MCP tools spec

  • ThingsBoard server-side RPC

F
license - not found
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer

  • MCP server for AI dialogue using various LLM models via AceDataCloud

  • MCP server for GLM chat completions using Zhipu AI models via AceDataCloud

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/syntechnology-engineering/MCPServer-SmartBuilding'

If you have feedback or need assistance with the MCP directory API, please join our Discord server