ThingsBoard Lighting MCP Server
An MCP server that lets Claude read and switch the 3 lights in the innovation room through ThingsBoard, using natural language.
README
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
- ทำอะไรได้ / What it does
- เริ่มใช้เร็ว / Quick start
- โครงสร้างโปรเจกต์ / Project layout
- Flow การทำงานทั้งหมด / Complete flow
- อธิบายโค้ดทีละไฟล์ / Module-by-module walkthrough
- สัญญาของ MCP tools / MCP tool contracts
- ข้อมูลอ้างอิงของ ThingsBoard / ThingsBoard reference
- การตั้งค่า / Configuration
- ต่อกับ Claude Desktop / Connecting Claude Desktop
- การทดสอบ / Testing
- แก้ปัญหา / Troubleshooting
- ต่อยอดอย่างไร / How to extend
- เหตุผลการออกแบบ / Design decisions
- ข้อจำกัดที่รู้อยู่ / Known gaps
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.json → bin |
./build/src/index.js |
package.json → scripts.start |
node --env-file=.env build/src/index.js |
test/index.handshake.test.ts → spawn(...) |
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:
-
รับ
envเป็น parameter ไม่อ่านprocess.envเอง — ทำให้เทสต์ได้โดยไม่ต้องยุ่งกับ global state เทสต์ส่ง object ธรรมดาเข้าไปได้เลย Takesenvas a parameter instead of readingprocess.envinternally, so tests pass plain objects and never mutate global state. -
รายงานตัวแปรที่ขาดทั้งหมดใน error เดียว ไม่ใช่ตัวแรกที่เจอ ใช้
zod.safeParseแล้ว maperror.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. -
เรียกที่ระดับ module ใน
index.ts→ ตายก่อน client ต่อเข้ามา การตั้งค่าผิดจะไม่ถูกเข้าใจผิดว่าเป็นปัญหาของอุปกรณ์ในภายหลัง Called at module level inindex.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:
-
ครบ 3 ดวง = ยิง
setStateLightAllครั้งเดียว "เปิดไฟทั้งห้อง" คือคำสั่งที่ใช้บ่อยที่สุด ยิง 3 ครั้งที่ยิงครั้งเดียวก็ได้ = เพิ่มโอกาสพลาดกลางทางเป็น 3 เท่า All three collapses to one call. Three round-trips where one suffices means three chances of a partial failure. -
ลำดับที่ caller ส่งมาไม่มีผล
["right","left","center"]=["left","center","right"]เพราะวนLIGHT_IDSCaller order is irrelevant because dispatch iteratesLIGHT_IDS. -
คืนค่าที่อ่านได้จริงหลังยิง ไม่ใช่ 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 storedoverridesมีไว้เพื่อสร้างสถานการณ์ที่อุปกรณ์ขัดแย้งกับตัวเอง สำหรับทดสอบ cross-check exists to manufacture self-contradicting device data for cross-check testscallsบันทึก RPC ทุกตัวรวมทั้งตัวที่ผิด ไม่ dedup ไม่เรียงใหม่ — เพราะเทสต์ต้องพิสูจน์ว่า "ครบ 3 ดวงยิงครั้งเดียว" ด้วยcalls.lengthrecords every RPC including invalid ones, socalls.lengthcan 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'senum BaseLightingalso lists astatekey. 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 -
.envis gitignored — never commit it. -
.env.exampleholds key names only. Do not fill in real values there: that file IS tracked by git (.gitignorehas!.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:
- path ต้องเป็น
build\src\index.jsไม่ใช่build\index.js(ดูหัวข้อ 3) - Claude Desktop ไม่อ่าน
.envตัวแปรต้องอยู่ในenvblock นี้เท่านั้น → รหัสจะอยู่ในไฟล์นี้แบบ plaintext ซึ่งเป็นทางเดียวที่ทำได้ Claude Desktop does not read.env; variables must live in thisenvblock, which means the password sits here in plaintext. That is the only supported way. - ต้อง ปิด 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
src/lighting.ts— เพิ่มในLightId,LIGHT_IDS,RPC_METHOD,TELEMETRY_KEY,TELEMETRY_KEYSsrc/index.ts— เพิ่มค่าในz.enum([...])และในStatusShape.lightssrc/mock.ts—stateเริ่มต้นและderivedmap- เทสต์ — 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:
- เปลี่ยน config เป็น map:
{ innovation: "<uuid>", meeting: "<uuid>" } - เพิ่ม parameter
roomใน tool ทั้งสองตัว เป็นz.enum([...])ของชื่อห้องที่รู้จักเท่านั้น ห้ามเป็นz.string()และห้ามรับ device id ดิบจาก Claude — ไม่งั้นขอบเขตหลุดทันที add aroomparameter as az.enumof known room names — neverz.string(), and never accept a raw device id from Claude 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 passthrough — thingsboard_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
- การเดารูปแบบข้อมูลแพงกว่าที่คิด spec เดารูปแบบค่า telemetry ไว้ 3 ทาง ผิดทั้ง 3 ทาง อุปกรณ์จริงส่ง
"ON"สิ่งเดียวที่ช่วยไว้คือ parser ที่ปฏิเสธค่าที่ไม่รู้จักแทนที่จะ default - เทสต์ที่ไม่เคยเห็นมันพัง ยังไม่นับเป็นเทสต์ เทสต์ที่คุม stdout เคยเขียนเป็น
filter(l => l.startsWith("{"))ซึ่งทิ้งconsole.logที่หลุดมาทั้งบรรทัด ทำให้เทสต์ไม่มีทาง fail เลย รู้ตัวตอนตั้งใจแอบใส่console.logเพื่อดูว่ามัน fail จริงไหม - ผ่านเทสต์ ≠ ถูก โค้ดจัดการ partial failure รอบแรกผ่าน 39 เทสต์ และผ่านการ probe ด้วยมือ แต่มันแยก "อ่านพลาด" กับ "ตั้งใจ throw" ด้วยการดมว่า error มี property
statusไหม — ซึ่งจะระเบิดทันทีที่ HTTP client เข้ามา (error ของ HTTP มี.statusเป็นเลข status code) แก้ด้วยการวางขอบเขต try/catch ให้ถูกจนไม่ต้องดมอะไรเลย chmod 755ใน build script คือกับดักข้าม platform มาจาก quickstart ของ MCP ที่เขียนบน macOS/Linux บน Windowsnpm 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
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.