cf-odoo-mcp-server
cf-odoo-mcp-server
MCP server สำหรับ Odoo ERP ที่รันบน Cloudflare Workers
ใช้ transport แบบ stateless HTTP — ไม่ต้องใช้ Durable Objects ไม่ต้องมี container ไม่ต้องมี process รันค้างไว้ อยู่ใน free tier ของ Workers ได้สบาย
พอร์ตมาจาก odoo-mcp-claude
ซึ่งเป็น Python process คุยผ่าน XML-RPC — 10 tools แรกยกมาจากที่นั่น ส่วน
odoo_read_group odoo_context และ odoo_get_models เพิ่มทีหลัง
แผนที่ POC — ทดลองอะไรไปแล้วบ้าง
โจทย์คือ "ให้ AI ตัวไหนก็ได้คุยกับ Odoo" ซึ่งกลายเป็นการทดลองหลายเส้นทาง เพราะ แต่ละ client ส่ง credential ได้ไม่เหมือนกัน และ Odoo บางแบบก็เอาออกเน็ตไม่ได้
client | ต่อกับ | auth | ผล |
Claude Code | repo นี้ (Cloudflare Worker) | bearer header | ✅ 13 tools |
Claude chat | repo นี้ | OAuth (DCR) | ✅ 13 tools |
ChatGPT | repo นี้ | OAuth (DCR) | ✅ 13 tools |
Gemini | repo นี้ | OAuth (DCR) | ✅ 13 tools |
ChatGPT | odoo-mcp-chatgpt (Docker + ท่อ OpenAI) | ท่อจัดการให้ | ✅ 13 tools |
Claude chat |
| — | ❌ Odoo ไม่ได้ทำ OAuth ให้ |
ChatGPT |
| — | ❌ เหตุผลเดียวกัน |
ข้อสรุปที่ไม่ได้คาดไว้: Claude, ChatGPT และ Gemini มีข้อจำกัดเดียวกันเป๊ะ ทั้งสามเจ้าตั้ง custom header ไม่ได้ รองรับแค่ OAuth หรือไม่มี auth ทั้งที่เป็นคนละ บริษัทคนละ implementation — ทั้งสามลงทะเบียนตัวเองผ่าน DCR (RFC 7591) โดยไม่ต้อง ตั้งค่าอะไรเพิ่มฝั่ง server เลย
แปลว่า OAuth คือทางเดียวที่ใช้กับ AI chat บนคลาวด์ได้ทุกเจ้า ส่วน static bearer
ใช้ได้เฉพาะ client ที่รันบนเครื่องเรา (Claude Code, Codex, curl) และ /mcp ในตัว
ของ Odoo จึงต่อกับ AI chat บนคลาวด์ไม่ได้เลยสักเจ้า ทั้งที่ทำมาให้ AI ใช้ เพราะ
ไม่ได้ทำ OAuth ให้
ฝั่ง Odoo ทดสอบกับสามเครื่อง — SaaS 19.4 Enterprise, 19.0 Community ที่ลงเอง
และ 18.0 สำหรับเส้นทาง fallback ของ odoo_read_group พฤติกรรมต่างกันจริงหลายจุด
จนต้องเขียนแยกไว้ทุกครั้งว่าผลไหนมาจากเครื่องไหน
สาม repo ต่างกันยังไง
repo | รันที่ไหน | client หลัก | Odoo ต้อง public |
Python process | ตัวตั้งต้น (XML-RPC) | ใช่ | |
cf-odoo-mcp-server (นี่) | Cloudflare edge | Claude Code · Claude chat | ใช่ |
Docker ข้าง Odoo | ChatGPT · Codex | ไม่ต้อง |
สองตัวหลังให้ tool ชุดเดียวกันและตั้งใจให้พฤติกรรมตรงกัน เลือกตามว่า Odoo ของคุณ ออกอินเทอร์เน็ตได้หรือไม่ ไม่ใช่ตามว่าใช้ AI ตัวไหน
บทเรียนที่ได้ — "สำเร็จแต่ไม่จริง"
สามในสี่ของบั๊กที่เจอเป็นเรื่องเดียวกัน คือ งานที่รายงานว่าสำเร็จ แต่ผลไม่ตรงกับ ที่สั่ง และไม่มีอะไรเตือน ซึ่งเป็นความล้มเหลวที่แย่ที่สุดสำหรับ agent เพราะมันจะ รายงานต่อผู้ใช้ว่าเรียบร้อยดี
อาการ | ทางแก้ |
Odoo ทิ้งค่าที่เขียนลง readonly field เงียบ ๆ |
|
อ่านมาไม่ครบแต่ดูเหมือนครบ | เพดาน 50 ของ |
นับกลุ่มได้ไม่ครบแล้วสรุปยอดผิด |
|
ข้อสุดท้ายเจอจากการใช้งานจริงบน Claude chat ไม่ใช่จากการทดสอบเอง — curl ไม่เคยขอ ให้ใครสรุปยอดจากผลที่ถูกตัด รายละเอียดทั้งหมดอยู่ใน NOTES.md
Odoo 19 Enterprise มี API key 2 ชนิด — เช็คก่อนว่าต้องใช้อันไหน
หัวข้อนี้ใช้กับ Enterprise เท่านั้น ถ้าใช้ Community ข้ามไปได้เลย — มีแต่ key
ชนิด rpc ซึ่งคือสิ่งที่ project นี้ใช้
บน Odoo 19 Enterprise ออก API key ได้ 2 scope และใช้แทนกันไม่ได้
ชนิด key | คุยกับ | ได้อะไร |
|
| MCP server ในตัว 5 tools อ่านอย่างเดียว — ไม่มี scope เขียนให้เปิด |
|
| เข้าถึง ORM ได้เต็ม project นี้ใช้อันนี้ |
การแบ่ง scope เข้มงวดทั้งสองทาง: key ชนิด rpc ยิง /mcp จะได้ 401
ส่วน key ชนิด mcp ก็ authenticate ผ่าน JSON-RPC ไม่ได้
MCP server ในตัวมาจาก module ai_mcp ซึ่งเป็น Enterprise (OEEL-1) — ถ้าใช้
Odoo Community จะไม่มี /mcp และไม่มี key ชนิด mcp ให้เลือก ข้ามหัวข้อนี้ไปได้เลย
ถ้าใช้ Enterprise และ agent ของคุณอ่านอย่างเดียว คุณอาจไม่ต้องใช้ project นี้เลยก็ได้
MCP server ในตัวของ Odoo ไม่ต้อง deploy ไม่ต้องหา hosting และไม่ต้องเอารหัส Odoo
ไปวางไว้ที่อื่น — ชี้ client ไปที่ https://<odoo-ของคุณ>/mcp พร้อม key ชนิด mcp ก็จบ
แถมมันยังปฏิเสธ technical model ให้เองโดยไม่ต้องตั้งค่า ส่วน project นี้ทำได้เหมือนกัน
แต่ต้องตั้ง BLOCKED_MODELS เอง ไม่ตั้งก็ไม่กัน
งานที่ต้องเขียนข้อมูลต้องใช้ key ชนิด rpc ซึ่งคือเหตุผลที่ project นี้มีอยู่ —
รวมถึงกรณีต่อ Odoo หลายตัวผ่าน endpoint เดียว และการมีเพดาน default ตอนอ่าน
ทั้งสองตัวรันคู่กันได้
NOTES.md มีผลเทียบแบบเต็ม รวมถึงจุดที่ schema ของ MCP server ในตัวจะทำให้ agent สะดุด
ทำไมใช้ JSON-RPC ไม่ใช่ XML-RPC
xmlrpc.client ของ Python ต้องใช้ raw socket ซึ่ง runtime ของ Workers ไม่มีให้
แต่ Odoo เปิด execute_kw ชุดเดียวกันผ่าน JSON-RPC ที่ /jsonrpc ซึ่งเป็น HTTP ธรรมดา
ใช้กับ fetch ได้ตรง ๆ ไม่ต้องดัดแปลงอะไร
ข้อกำหนด
Odoo ต้องเข้าถึงได้จากอินเทอร์เน็ตผ่าน HTTPS — Worker วิ่งเข้า LAN ไม่ได้ ถ้า Odoo ยังไม่ public ให้เอา Cloudflare Tunnel มาคั่นไว้ข้างหน้า
Tools
Tool | method ของ Odoo |
| — (แสดง server ที่ตั้งค่าไว้) |
|
|
|
|
|
|
|
|
|
|
|
|
| public method ใดก็ได้ (Odoo กัน private method เอง) |
|
|
|
|
| ผู้ใช้ บริษัท timezone ภาษา |
| รายชื่อ model ที่ใช้ได้ — ต้องสิทธิ์ Access Rights ดูหมายเหตุใต้ตาราง |
|
|
odoo_get_models อ่าน ir.model ซึ่ง Odoo สงวนไว้ให้กลุ่ม Access Rights หรือ
Administrator บัญชีสิทธิ์ต่ำตามที่แนะนำข้างล่างจะเรียกไม่ได้ อีก 12 ตัวใช้ได้ครบ
การตั้งค่า
ตัวแปร | ใช้ทำอะไร |
| จำเป็น bearer token ที่ผู้เรียกต้องแนบมา |
| JSON รองรับหนึ่งหรือหลาย server ถ้าตั้งไว้จะชนะตัวข้างล่าง |
| ทางเลือกสำรองสำหรับ server เดียว |
| ไม่บังคับ รายการ model ที่ทุก tool จะปฏิเสธ คั่นด้วย comma ลงท้าย |
| ไม่บังคับ ถ้าตั้งไว้ model ต้องตรงรายการนี้ด้วยจึงจะใช้ได้ |
| จำเป็น KV namespace เก็บ client/grant/token ของ OAuth (ผูกใน |
| Rate limiting binding ผูกใน |
| ไม่บังคับ รายชื่อ hostname คั่นด้วย comma ที่ยอมให้ browser |
ควรใช้ API key ของ Odoo แทนรหัสผ่านบัญชี และ อย่าใช้บัญชี admin —
odoo_delete กับ odoo_write เข้าถึงได้ทุกอย่างที่บัญชีนั้นเข้าถึงได้
สูตรบัญชีเฉพาะที่ทดสอบแล้วอยู่ใน
NOTES.md
BLOCKED_MODELS เป็นรั้วชั้นที่สอง ตั้งได้โดยไม่ต้องไปแก้สิทธิ์ใน Odoo ค่าที่แนะนำ
BLOCKED_MODELS="ir.*,res.users*,res.groups*"ลงท้ายด้วย * ด้วย — res.users เฉย ๆ จับได้แค่ตัวมันเอง res.users.apikeys
ยังหลุดผ่าน ส่วน odoo_context ตั้งใจให้ข้ามรั้วนี้ เพราะมันอ่านแค่ตัวตนของ
connection ที่มีอยู่แล้ว
Odoo กัน private method ให้เองทุกแพลตฟอร์ม แต่ การซ่อนตาราง ACL อย่าง
ir.model.access เกิดเฉพาะบน Odoo Online — บน Community ที่ลงเอง ตารางพวกนี้
อ่านและเขียนได้ตามสิทธิ์บัญชี รั้วนี้จึงยิ่งจำเป็นถ้ารัน Odoo เอง
รายละเอียดที่วัดมาทั้งสองแพลตฟอร์มอยู่ใน NOTES.md
Rate limiting — ผูกไว้แล้วแต่ยังกันไม่ได้จริง
wrangler.jsonc ผูก Workers Rate Limiting binding ไว้สองตัว — MCP_LIMIT
(120/60s ที่ /mcp) กับ AUTH_LIMIT (10/60s ที่หน้า consent) key เป็น
CF-Connecting-IP ซึ่ง Cloudflare เขียนทับเสมอ ปลอมไม่ได้
แต่ทดสอบแล้วมันไม่ปฏิเสธ request ที่ยิงมาแยกกัน ยิง /authorize 60 ครั้ง
ทั้งที่ลิมิต 10 ก็ไม่เจอ 429 สักครั้ง ขณะที่เรียก limit() รวดใน request เดียว
ปฏิเสธหลังครั้งที่ 11 ตามที่ควร ไล่ตัดสาเหตุแล้วไม่ใช่เรื่อง key และ binding
มาถึงโค้ดจริง — เหลือคำอธิบายเดียวคือ counter แยกตาม isolate แล้ว sync ไม่ทัน
ซึ่งตรงกับที่เอกสาร Cloudflare เตือนเองว่า permissive, eventually consistent
อย่าพึ่งมันเป็นด่านเดียว โค้ดยังอยู่เพราะถูกต้องและไม่มีต้นทุน อาจทำงานตอน
โดนยิงหนักกว่านี้ ถ้าต้องการการกันที่แม่นจริงต้องใช้ Durable Object นับแทน หรือ
ผูกโดเมนของตัวเองแล้วใช้ WAF ของ Cloudflare ซึ่งทำงานระดับ zone จึงใช้กับ
workers.dev ไม่ได้
ตัวเลขที่วัดได้อยู่ใน NOTES.md
ODOO_SERVERS หน้าตาแบบนี้
{
"default_server": "prod",
"servers": {
"prod": { "url": "https://odoo.example.com", "db": "mydb", "username": "bot@example.com", "password": "api-key" }
}
}พัฒนาบนเครื่อง
npm install
cp .dev.vars.example .dev.vars # แล้วกรอกค่าให้ครบ
npm run dev
npm run typecheck
npm test # ไม่ต้องมี Odoo — mock fetch เอาtest ครอบเฉพาะตรรกะที่พังเงียบได้และไม่ต้องพึ่ง Odoo จริง — การจับ * ของ
BLOCKED_MODELS, การอ่าน ODOO_SERVERS, การลองใหม่เมื่อ uid ค้าง และการเทียบ
ค่าที่เขียนกับค่าที่ Odoo เก็บจริง
สามในสี่ชุดนั้นมีบั๊กเกิดขึ้นจริงมาแล้ว (res.users ที่ไม่จับ res.users.apikeys
และ html field ที่ถูกฟ้องผิดว่าค่าไม่เข้า) test จึงเขียนจากสิ่งที่พลาดมาแล้ว
ไม่ใช่จากการไล่ให้ครบทุกไฟล์
CI รันสามอย่างนี้ทุก push และ PR — typecheck, test
และ wrangler deploy --dry-run เพื่อจับกรณีที่ compile ผ่านแต่ bundle ไม่ขึ้น
.dev.vars ถูก gitignore ไว้แล้ว อย่า commit รหัสผ่านเด็ดขาด
Deploy
npx wrangler login
# OAuth ต้องใช้ KV เก็บ client/grant/token — เอา id ที่ได้ไปใส่ wrangler.jsonc
npx wrangler kv namespace create OAUTH_KV
npx wrangler deploy
npx wrangler secret put MCP_AUTH_TOKEN # openssl rand -hex 32
npx wrangler secret put ODOO_URL
npx wrangler secret put ODOO_DB
npx wrangler secret put ODOO_USERNAME
npx wrangler secret put ODOO_PASSWORD
npx wrangler secret put BLOCKED_MODELS # ir.*,res.users*,res.groups*secret ถูกเข้ารหัสตอนเก็บ และไม่โผล่ใน wrangler.jsonc
ต่อ client
.mcp.json ในโฟลเดอร์นี้เป็นไฟล์ของ Claude Code ไม่ใช่ของ Worker — มันแค่บอก
client ว่าจะไปคุยกับ endpoint ไหนด้วย token อะไร ไฟล์นี้อ้างค่าจาก environment
จึงไม่มีความลับอยู่ในตัวและ commit ขึ้น git ได้
{
"mcpServers": {
"odoo": {
"type": "streamable-http",
"url": "${ODOO_MCP_URL:-https://cf-odoo-mcp-server.<subdomain>.workers.dev/mcp}",
"headers": { "Authorization": "Bearer ${MCP_AUTH_TOKEN}" }
}
}
}Claude Code ไม่ได้โหลด .env ให้เอง ต้อง export เข้า environment ก่อนเรียก
cp .env.example .env # แล้วกรอก MCP_AUTH_TOKEN
set -a; . ./.env; set +a
claudeถ้าลืม export ตัวแปรจะไม่ถูกแทนค่า claude mcp list จะเตือนว่าหาตัวแปรไม่เจอ
และ Worker จะตอบ 401 — พังแบบรู้ตัว ไม่ใช่หลุดเงียบ ๆ
ODOO_MCP_URL มีไว้ชี้ไป Worker คนละตัวได้ เช่น staging กับ production โดยไม่ต้อง
แก้ .mcp.json
client ตัวอื่นที่ไม่ได้อ่าน .mcp.json ให้ตั้ง endpoint กับ header Authorization: Bearer <token> เองตามรูปแบบเดียวกัน
GET /health ไม่ต้อง auth คืน {"status":"ok"}
ต่อจาก Claude (claude.ai / Desktop / มือถือ)
connector ของ Claude ตั้ง header เองไม่ได้ (ฟีเจอร์นั้นยัง beta) จึงต้องใช้ OAuth ซึ่ง server นี้รองรับแล้ว — Customize → Connectors → Add custom connector
ช่อง | ใส่อะไร |
URL |
|
Authentication | Always required (Claude ตรวจเจอเอง) |
OAuth client | No client ID — register one automatically (DCR) |
กด Connect แล้วจะเด้งมาหน้า consent ของ server นี้ ใส่ MCP_AUTH_TOKEN แล้วกดอนุญาต
ที่ใช้ token เดิมเป็นรหัสยืนยันแทนการสร้างบัญชีใหม่ เพราะ server นี้มีความลับตัวเดียว อยู่แล้ว การเพิ่มอีกตัวคือเพิ่มของที่ต้องดูแลโดยไม่ได้ปลอดภัยขึ้น — ผลคือ token เดียวทำสองหน้าที่ เป็น static bearer ของ client ที่ส่ง header ได้ และเป็นรหัสหน้า consent ของ client ที่ส่งไม่ได้ เปลี่ยน token เมื่อไหร่ต้องต่อ connector ใหม่ด้วย
ทั้งสองทางลงเอยที่ handler เดียวกัน request ที่พก static bearer ถูกต้องเข้าตรง ๆ ไม่แตะ OAuth เลย ส่วนที่เหลือตกเป็นของ OAuth provider
ความปลอดภัย
endpoint นี้เป็น public ทุก request ที่เข้า /mcp จึงต้องแนบ bearer token มาด้วย
และเทียบแบบ constant time ถ้าไม่ได้ตั้ง MCP_AUTH_TOKEN ไว้ Worker จะคืน 500
แทนที่จะเปิดโล่ง
token ตัวเดียวใช้ร่วมกันเหมาะกับ server ส่วนตัวหรือใช้ภายใน ถ้าต้องแยกตัวตนรายคน ให้เอา Workers OAuth Provider มาคั่นข้างหน้าแทน
บันทึกจากการใช้งานจริง
NOTES.md บันทึกสิ่งที่ทดสอบกับ Odoo ตัวจริงแล้ว และข้อควรระวังที่สำคัญ เวลาให้ AI agent เป็นคนสั่ง tool เหล่านี้ — โดยเฉพาะเรื่องที่ Odoo ทิ้งค่าที่เขียนลง readonly field ไปเงียบ ๆ
License
MIT