Odoo × Claude(MCP)で変わる業務の景色 — 小さな MCP サーバーを書いて試した
Claude などの AI から Odoo を「道具」として呼ぶ仕組みが MCP です。標準ライブラリだけで小さな MCP サーバーを書き、見本のデータの Odoo 19 Community で見積の一覧から見積の作成まで呼び、権限で止まることも確かめました。
目次
目次
このページには目次がありません

「先月の未入金を出して」「この得意先に見積を作っておいて」。こうした頼みごとを AI に投げて、AI が業務システムを呼ぶ。その「呼び口」の決まりが MCP(Model Context Protocol)です。
この記事では、外部の MCP サーバーを入れずに、当社で小さな MCP サーバーを書き、見本のデータの入った Odoo 19 Community で実際に呼びました。何が呼べて、どこで止まるかを、件数で確かめています。
MCP とは:AI に「道具の一覧」を渡す決まり
MCP は、AI のアプリ(クライアント)と、業務システムの前に立つ小さなプログラム(サーバー)とのやり取りの決まりです。
- サーバーは 道具の一覧(名前・説明・受け取る値の形)を出す
- AI のアプリはその一覧を見て、必要な道具を 名前と値を添えて呼ぶ
- サーバーは業務システムを呼び、結果を返す
やり取りは JSON-RPC 2.0 という形の短い文で、手元で動かすときは 標準入出力に 1 行ずつ 流します。AI が何をするかは「一覧にある道具を呼べる」ところまでで、道具に無いことはできません。どの道具を置くかが、そのまま AI に任せる範囲になります。
当社が書いた小さな MCP サーバー
Python の標準ライブラリ(json・sys・urllib.request)だけで書きました。外部の部品は入れていません。行数は 219 行です。Odoo 19 には、API キーで呼べる /json/2/<モデル>/<メソッド> という呼び口があり、サーバーはこれを使います。
置いた道具は 5 個です。
| 道具 | 中身 | 種類 |
|---|---|---|
list_quotations | 確定前の見積の一覧 | 読む |
list_sales_orders | 確定した受注の一覧(請求の状態つき) | 読む |
stock_on_hand | 品目ごとの手持ちと見込みの数 | 読む |
unpaid_invoices | 未入金の請求書(支払期日の順) | 読む |
create_quotation | 見積を下書きで作る(確定・送付はしない) | 作る |
要は、Odoo を呼ぶところです。誰として動くかは API キーの持ち主で決まるので、Odoo の権限がそのまま効きます。
def odoo(model, method, **kwargs):
"""POST /json/2/<model>/<method>。引数は名前つきで渡す(Odoo 19 の JSON-2 API)。"""
kwargs.setdefault("context", CTX)
req = urllib.request.Request(
f"{ODOO_URL}/json/2/{model}/{method}",
data=json.dumps(kwargs).encode("utf-8"),
headers={
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"bearer {API_KEY}", # キーは環境変数から
"X-Odoo-Database": ODOO_DB,
},
method="POST",
)
...
見積を作る道具は、得意先と品目を 1 件に絞れなければ作らずに断ります。曖昧なまま作らないためです。
def create_quotation(customer, lines, note=""):
"""見積を下書きで 1 件作る。確定(受注にする)はしない。"""
partners = odoo("res.partner", "search_read",
domain=[["name", "ilike", customer], ["customer_rank", ">", 0]],
fields=["id", "name"], limit=2)
if len(partners) != 1:
raise OdooError(f"得意先「{customer}」が {len(partners)} 件見つかりました。1 件に絞れる名前で。")
...
ids = odoo("sale.order", "create", vals_list=[vals])
MCP の側は、initialize(あいさつ)・tools/list(道具の一覧)・tools/call(道具を呼ぶ)の 3 つを手で書けば足ります。道具の中で起きた失敗は、通信の失敗とは分けて isError: true で返します。AI のアプリが、断られた理由を読んで次の手を選べるようにするためです。
実際に呼んだやり取り
検証用のクライアントからサーバーを子プロセスで起こし、1 行ずつ送りました。API キーは環境変数でだけ渡しています(下では伏せています)。
$ ODOO_URL=http://localhost:8079 ODOO_DB=sb_mcp ODOO_API_KEY=**** python odoo_mcp.py
→ {"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-11-25",...},"id":1}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25",
"capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"odoo-mcp-prototype",...}}}
→ {"jsonrpc":"2.0","method":"tools/list","id":2}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"list_quotations","title":"見積の一覧",...},
{"name":"list_sales_orders",...},{"name":"stock_on_hand",...},{"name":"unpaid_invoices",...},
{"name":"create_quotation","title":"見積を作る",...}]}}
→ {"jsonrpc":"2.0","method":"tools/call","params":{"name":"unpaid_invoices","arguments":{}},"id":6}
← {"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"[
{\"番号\":\"INV/2026/00003\",\"得意先\":\"青空スーパー\",\"支払期日\":\"2026-10-16\",\"未入金\":128920.0},
{\"番号\":\"INV/2026/00004\",\"得意先\":\"ほしぞら機工株式会社\",\"支払期日\":\"2026-10-24\",\"未入金\":133100.0},
{\"番号\":\"INV/2026/00005\",\"得意先\":\"つばさ物産株式会社\",\"支払期日\":\"2026-10-30\",\"未入金\":462000.0}]"}],
"isError":false}}
→ {"jsonrpc":"2.0","method":"tools/call","params":{"name":"create_quotation","arguments":{
"customer":"ほしぞら機工","lines":[{"product":"FG-CU100","qty":2},{"product":"SV-INST","qty":1}],
"note":"MCP の試作から作った見積"}},"id":7}
← {"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":
"{\"番号\":\"S00012\",\"得意先\":\"ほしぞら機工株式会社\",\"合計\":133100.0,\"状態\":\"draft\",\"id\":12}"}],
"isError":false}}
読む道具が返した件数は、見積 3 件・受注 8 件・未入金の請求書 3 件で、どれも DB を直接数えた件数と一致しました。見積を作る道具を呼ぶ前と後で数えると、見積は 3 件 → 4 件、販売オーダの総数は 11 件 → 12 件 に増えています。
Odoo の画面で開くと、MCP から作った見積が下書きのまま入っていました。明細は 2 行、税抜 121,000 円、合計 133,100 円です。
MCP の道具で作った見積。確定はされていない(画面は見本のデータです)
見積の一覧の先頭に入った(画面は見本のデータです)
右側の記録には、作った人として API キーの持ち主 の名前が出ます。AI 用の利用者を分けておけば、誰が画面で作り、何が AI 経由で作られたかを見分けられます。
権限で止まる
Odoo の販売・購買には「読むだけ」の役割が標準でありません。そこで、販売・購買・請求・在庫の記録を 読むだけ の役割を作り(読み取り 8 個)、その利用者の API キーで同じ道具を呼びました。
→ {"jsonrpc":"2.0","method":"tools/call","params":{"name":"create_quotation",...},"id":7}
← {"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":
"Odoo が 403 で断りました。odoo.exceptions.AccessError:
あなたは '販売オーダ' (sale.order) レコードの作成権がありません。
この操作は、次のグループで許可されています: - 販売/管理者 - 販売/ユーザ: 自分のドキュメントのみ"}],
"isError":true}}
見積は作られず、販売オーダの総数は 12 件のままでした。止めたのは MCP サーバーではなく Odoo の権限 です。サーバーの側で道具を隠し忘れても、キーの持ち主に権限が無ければ書けません。
もう 1 つ分かったことがあります。読むだけの役割で、最初は 在庫の道具も 403 で止まりました。在庫の数を出すとき、Odoo は部品表(mrp.bom)も読みに行くためです。部品表と明細の読み取りを 2 個 足すと通りました。役割は、紙の上で決めるより、道具を通して呼んでみて 足りない読み取りを見つける方が確実です。
なお、存在しない API キーで呼ぶと 401 で断られます。
Claude などの AI から使うときの決まり
この記事の作業では、Claude のアプリなど実際の AI からはつないでいません。検証用のクライアントから MCP の通りに呼んだところまでです。AI のアプリからつなぐときは、アプリの設定にこのサーバーを起こす命令を登録し、AI はそこに並んだ道具を呼びます。そのときの決まりを、試作から分かったこととしてまとめます。
- 読むだけの利用者で始める。AI 用の利用者を別に作り、まずは読む役割だけを付けます。書く道具を足すのは、読む道具の答えが業務で使えると分かってから
- 書く道具は下書きまで。見積・発注・請求書の確定、メールの送付、入金の消込は道具に置きません。確定は人が画面で行います
- キーは環境変数で渡す。コードや設定のファイルにキーを書きません。Odoo の API キーには期限を付けられ、利用者の画面から消せます
- AI 用の利用者の名前を分ける。作られた記録に、誰の名前で入ったかが残ります
- 答えを Odoo の数と突き合わせる。道具の答えは、最初のうちは画面の一覧の件数と比べます。この試作でも、件数は DB を直接数えて確かめました
- 仕様の版を見ておく。この試作は
initializeで始まる版(2025-11-25)の形で書きました。最新の版(2026-07-28)はこの手続きを持たず、1 通ごとに版を添える形です。AI のアプリがどちらで話すかは、つなぐ前に確かめます
当社が用意する物
- 業務に合わせた道具の一覧:どの問いに答え、どこまで作るかを、業務の流れから決めて道具にします
- 読むだけの役割:Odoo の権限で、AI 用の利用者に渡す読み取りの範囲を作り、道具を通して試します
- AI 用の利用者とキーの運用:利用者の分け方・キーの期限・止め方を用意します
- 呼ばれた道具の記録:いつ・どの道具が・どの値で呼ばれたかを残す仕組みを足します
- 新しい版の MCP への対応:今回の試作は古い形だけに応じています。最新の版の形も足します
試作のサーバーのために、Odoo へアドオンは何も足していません(道具で作った見積の下書きは、Odoo の標準の見積として残ります)。Odoo 19 Community の標準の呼び口と権限だけで動いています。自社の業務でどの道具から始めるかは、「本格拡張はアドオンで」とあわせてご相談ください。

