Skip to main content
Glama
monthop-gmail

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

/mcp ในตัวของ Odoo

❌ Odoo ไม่ได้ทำ OAuth ให้

ChatGPT

/mcp ในตัวของ Odoo

❌ เหตุผลเดียวกัน

ข้อสรุปที่ไม่ได้คาดไว้: 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

odoo-mcp-claude

Python process

ตัวตั้งต้น (XML-RPC)

ใช่

cf-odoo-mcp-server (นี่)

Cloudflare edge

Claude Code · Claude chat

ใช่

odoo-mcp-chatgpt

Docker ข้าง Odoo

ChatGPT · Codex

ไม่ต้อง

สองตัวหลังให้ tool ชุดเดียวกันและตั้งใจให้พฤติกรรมตรงกัน เลือกตามว่า Odoo ของคุณ ออกอินเทอร์เน็ตได้หรือไม่ ไม่ใช่ตามว่าใช้ AI ตัวไหน

บทเรียนที่ได้ — "สำเร็จแต่ไม่จริง"

สามในสี่ของบั๊กที่เจอเป็นเรื่องเดียวกัน คือ งานที่รายงานว่าสำเร็จ แต่ผลไม่ตรงกับ ที่สั่ง และไม่มีอะไรเตือน ซึ่งเป็นความล้มเหลวที่แย่ที่สุดสำหรับ agent เพราะมันจะ รายงานต่อผู้ใช้ว่าเรียบร้อยดี

อาการ

ทางแก้

Odoo ทิ้งค่าที่เขียนลง readonly field เงียบ ๆ

fields_not_applied

อ่านมาไม่ครบแต่ดูเหมือนครบ

เพดาน 50 ของ odoo_search_read

นับกลุ่มได้ไม่ครบแล้วสรุปยอดผิด

has_more + total_records ของ odoo_read_group

ข้อสุดท้ายเจอจากการใช้งานจริงบน 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

/mcp ของ Odoo เอง

MCP server ในตัว 5 tools อ่านอย่างเดียว — ไม่มี scope เขียนให้เปิด

rpc

/jsonrpc

เข้าถึง 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

odoo_list_servers

— (แสดง server ที่ตั้งค่าไว้)

odoo_search_read

search_read

odoo_search_count

search_count

odoo_read

read

odoo_create

create แล้วอ่าน field ที่เขียนไปกลับมา

odoo_write

write แล้วอ่าน field ที่เขียนไปกลับมา

odoo_delete

unlink

odoo_execute

public method ใดก็ได้ (Odoo กัน private method เอง)

odoo_fields_get

fields_get

odoo_read_group

formatted_read_group (ถอยไป read_group ถ้าเป็น Odoo รุ่นเก่า) — บอก has_more เมื่อผลถูกตัด

odoo_context

ผู้ใช้ บริษัท timezone ภาษา

odoo_get_models

รายชื่อ model ที่ใช้ได้ — ต้องสิทธิ์ Access Rights ดูหมายเหตุใต้ตาราง

odoo_version

common.version

odoo_get_models อ่าน ir.model ซึ่ง Odoo สงวนไว้ให้กลุ่ม Access Rights หรือ Administrator บัญชีสิทธิ์ต่ำตามที่แนะนำข้างล่างจะเรียกไม่ได้ อีก 12 ตัวใช้ได้ครบ

การตั้งค่า

ตัวแปร

ใช้ทำอะไร

MCP_AUTH_TOKEN

จำเป็น bearer token ที่ผู้เรียกต้องแนบมา

ODOO_SERVERS

JSON รองรับหนึ่งหรือหลาย server ถ้าตั้งไว้จะชนะตัวข้างล่าง

ODOO_URL ODOO_DB ODOO_USERNAME ODOO_PASSWORD

ทางเลือกสำรองสำหรับ server เดียว

BLOCKED_MODELS

ไม่บังคับ รายการ model ที่ทุก tool จะปฏิเสธ คั่นด้วย comma ลงท้าย * เพื่อจับแบบขึ้นต้น เช่น ir.*,res.users* ถ้าไม่ตั้งจะไม่บล็อกอะไรเลย

ALLOWED_MODELS

ไม่บังคับ ถ้าตั้งไว้ model ต้องตรงรายการนี้ด้วยจึงจะใช้ได้

OAUTH_KV

จำเป็น KV namespace เก็บ client/grant/token ของ OAuth (ผูกใน wrangler.jsonc)

MCP_LIMIT AUTH_LIMIT

Rate limiting binding ผูกใน wrangler.jsoncอ่านข้อจำกัดที่วัดได้ก่อนพึ่งพา

ALLOWED_ORIGIN_HOSTNAMES

ไม่บังคับ รายชื่อ hostname คั่นด้วย comma ที่ยอมให้ browser Origin เรียก /mcp ได้ หรือใส่ * ถ้าไม่ตั้ง จะรับเฉพาะ localhost กับ hostname workers.dev ของ Worker เอง — client ฝั่ง server ไม่ส่ง Origin มาอยู่แล้วจึงไม่ได้รับผลกระทบ

ควรใช้ API key ของ Odoo แทนรหัสผ่านบัญชี และ อย่าใช้บัญชี adminodoo_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

https://<subdomain>.workers.dev/mcp

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