#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { OdooClient } from "./odoo-client.js";
import { z } from "zod";
// Get config from environment
const config = {
url: process.env.ODOO_URL || "https://odoo.deeprunner.ai",
db: process.env.ODOO_DB || "deeprunner",
username: process.env.ODOO_USERNAME || "",
password: process.env.ODOO_PASSWORD || "",
};
if (!config.username || !config.password) {
console.error("Error: ODOO_USERNAME and ODOO_PASSWORD environment variables are required");
process.exit(1);
}
const odoo = new OdooClient(config);
// Tool definitions
const tools: Tool[] = [
// ============ GENERIC TOOLS ============
{
name: "odoo_search",
description: "Search for records in any Odoo model using domain filters. Returns matching record IDs.",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name (e.g., 'product.product', 'res.partner', 'sale.order')",
},
domain: {
type: "array",
description: "Search domain as array of conditions. Example: [[\"name\", \"ilike\", \"sensor\"], [\"type\", \"=\", \"product\"]]",
},
limit: {
type: "number",
description: "Maximum number of records to return",
},
order: {
type: "string",
description: "Sort order (e.g., 'name asc', 'create_date desc')",
},
},
required: ["model", "domain"],
},
},
{
name: "odoo_read",
description: "Read specific fields from records by their IDs",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name",
},
ids: {
type: "array",
items: { type: "number" },
description: "List of record IDs to read",
},
fields: {
type: "array",
items: { type: "string" },
description: "List of field names to retrieve. Leave empty for all fields.",
},
},
required: ["model", "ids"],
},
},
{
name: "odoo_search_read",
description: "Search and read records in one call. Most efficient way to get data.",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name",
},
domain: {
type: "array",
description: "Search domain as array of conditions",
},
fields: {
type: "array",
items: { type: "string" },
description: "List of field names to retrieve",
},
limit: {
type: "number",
description: "Maximum number of records",
},
order: {
type: "string",
description: "Sort order",
},
},
required: ["model", "domain"],
},
},
{
name: "odoo_create",
description: "Create a new record in any Odoo model",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name",
},
values: {
type: "object",
description: "Field values for the new record",
},
},
required: ["model", "values"],
},
},
{
name: "odoo_update",
description: "Update existing records in any Odoo model",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name",
},
ids: {
type: "array",
items: { type: "number" },
description: "List of record IDs to update",
},
values: {
type: "object",
description: "Field values to update",
},
},
required: ["model", "ids", "values"],
},
},
{
name: "odoo_delete",
description: "Delete records from any Odoo model",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name",
},
ids: {
type: "array",
items: { type: "number" },
description: "List of record IDs to delete",
},
},
required: ["model", "ids"],
},
},
{
name: "odoo_get_fields",
description: "Get field definitions for an Odoo model. Useful to understand available fields.",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name",
},
},
required: ["model"],
},
},
{
name: "odoo_execute",
description: "Execute any method on an Odoo model. For advanced operations like workflow actions.",
inputSchema: {
type: "object",
properties: {
model: {
type: "string",
description: "Odoo model name",
},
method: {
type: "string",
description: "Method name to call",
},
args: {
type: "array",
description: "Positional arguments for the method",
},
kwargs: {
type: "object",
description: "Keyword arguments for the method",
},
},
required: ["model", "method"],
},
},
// ============ INVENTORY TOOLS ============
{
name: "inventory_list_products",
description: "List products in inventory with stock quantities",
inputSchema: {
type: "object",
properties: {
category: {
type: "string",
description: "Filter by product category name",
},
search: {
type: "string",
description: "Search term for product name or code",
},
limit: {
type: "number",
description: "Maximum number of products to return",
default: 50,
},
},
},
},
{
name: "inventory_get_stock",
description: "Get current stock levels for products across warehouses",
inputSchema: {
type: "object",
properties: {
product_ids: {
type: "array",
items: { type: "number" },
description: "Product IDs to check stock for",
},
warehouse_id: {
type: "number",
description: "Specific warehouse ID to check (optional)",
},
},
required: ["product_ids"],
},
},
{
name: "inventory_list_warehouses",
description: "List all warehouses and their stock locations",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "inventory_list_operations",
description: "List pending inventory operations (receipts, deliveries, transfers)",
inputSchema: {
type: "object",
properties: {
operation_type: {
type: "string",
enum: ["incoming", "outgoing", "internal"],
description: "Filter by operation type",
},
state: {
type: "string",
enum: ["draft", "waiting", "confirmed", "assigned", "done", "cancel"],
description: "Filter by state",
},
limit: {
type: "number",
description: "Maximum number of operations to return",
default: 20,
},
},
},
},
{
name: "inventory_create_transfer",
description: "Create an internal stock transfer between locations",
inputSchema: {
type: "object",
properties: {
from_location_id: {
type: "number",
description: "Source location ID",
},
to_location_id: {
type: "number",
description: "Destination location ID",
},
products: {
type: "array",
items: {
type: "object",
properties: {
product_id: { type: "number" },
quantity: { type: "number" },
},
},
description: "Products and quantities to transfer",
},
scheduled_date: {
type: "string",
description: "Scheduled date (YYYY-MM-DD)",
},
},
required: ["from_location_id", "to_location_id", "products"],
},
},
// ============ CRM / SALES TOOLS ============
{
name: "crm_list_leads",
description: "List CRM leads/opportunities",
inputSchema: {
type: "object",
properties: {
stage: {
type: "string",
description: "Filter by stage name",
},
user_id: {
type: "number",
description: "Filter by salesperson user ID",
},
limit: {
type: "number",
description: "Maximum number of leads to return",
default: 20,
},
},
},
},
{
name: "crm_create_lead",
description: "Create a new CRM lead/opportunity",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Lead/opportunity name",
},
partner_id: {
type: "number",
description: "Customer partner ID",
},
contact_name: {
type: "string",
description: "Contact person name",
},
email_from: {
type: "string",
description: "Contact email",
},
phone: {
type: "string",
description: "Contact phone",
},
expected_revenue: {
type: "number",
description: "Expected revenue amount",
},
description: {
type: "string",
description: "Lead description/notes",
},
},
required: ["name"],
},
},
{
name: "sales_list_orders",
description: "List sales orders",
inputSchema: {
type: "object",
properties: {
state: {
type: "string",
enum: ["draft", "sent", "sale", "done", "cancel"],
description: "Filter by order state",
},
partner_id: {
type: "number",
description: "Filter by customer ID",
},
limit: {
type: "number",
description: "Maximum number of orders to return",
default: 20,
},
},
},
},
{
name: "sales_create_order",
description: "Create a new sales order (quotation)",
inputSchema: {
type: "object",
properties: {
partner_id: {
type: "number",
description: "Customer partner ID",
},
lines: {
type: "array",
items: {
type: "object",
properties: {
product_id: { type: "number" },
quantity: { type: "number" },
price_unit: { type: "number" },
},
},
description: "Order lines with product, quantity, and optional price",
},
note: {
type: "string",
description: "Order notes",
},
},
required: ["partner_id", "lines"],
},
},
{
name: "sales_confirm_order",
description: "Confirm a quotation to convert it to a sales order",
inputSchema: {
type: "object",
properties: {
order_id: {
type: "number",
description: "Sales order ID to confirm",
},
},
required: ["order_id"],
},
},
// ============ CONTACTS / PARTNERS TOOLS ============
{
name: "contacts_list",
description: "List contacts/partners (customers, vendors, etc.)",
inputSchema: {
type: "object",
properties: {
search: {
type: "string",
description: "Search term for name or email",
},
is_customer: {
type: "boolean",
description: "Filter customers only",
},
is_vendor: {
type: "boolean",
description: "Filter vendors only",
},
limit: {
type: "number",
description: "Maximum number of contacts to return",
default: 50,
},
},
},
},
{
name: "contacts_create",
description: "Create a new contact/partner",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Contact name",
},
email: {
type: "string",
description: "Email address",
},
phone: {
type: "string",
description: "Phone number",
},
street: {
type: "string",
description: "Street address",
},
city: {
type: "string",
description: "City",
},
country_code: {
type: "string",
description: "Country code (e.g., 'US', 'GB')",
},
is_company: {
type: "boolean",
description: "True if this is a company, false for individual",
},
customer_rank: {
type: "number",
description: "Set to 1 to mark as customer",
},
supplier_rank: {
type: "number",
description: "Set to 1 to mark as vendor",
},
},
required: ["name"],
},
},
// ============ MANUFACTURING TOOLS ============
{
name: "manufacturing_list_orders",
description: "List manufacturing orders",
inputSchema: {
type: "object",
properties: {
state: {
type: "string",
enum: ["draft", "confirmed", "progress", "to_close", "done", "cancel"],
description: "Filter by state",
},
product_id: {
type: "number",
description: "Filter by product ID",
},
limit: {
type: "number",
description: "Maximum number of orders to return",
default: 20,
},
},
},
},
{
name: "manufacturing_create_order",
description: "Create a new manufacturing order",
inputSchema: {
type: "object",
properties: {
product_id: {
type: "number",
description: "Product ID to manufacture",
},
quantity: {
type: "number",
description: "Quantity to produce",
},
bom_id: {
type: "number",
description: "Bill of Materials ID (optional, uses default if not specified)",
},
scheduled_date: {
type: "string",
description: "Scheduled start date (YYYY-MM-DD)",
},
},
required: ["product_id", "quantity"],
},
},
{
name: "manufacturing_list_boms",
description: "List Bills of Materials",
inputSchema: {
type: "object",
properties: {
product_id: {
type: "number",
description: "Filter by product ID",
},
limit: {
type: "number",
description: "Maximum number of BOMs to return",
default: 20,
},
},
},
},
// ============ PURCHASE TOOLS ============
{
name: "purchase_list_orders",
description: "List purchase orders",
inputSchema: {
type: "object",
properties: {
state: {
type: "string",
enum: ["draft", "sent", "to approve", "purchase", "done", "cancel"],
description: "Filter by state",
},
partner_id: {
type: "number",
description: "Filter by vendor ID",
},
limit: {
type: "number",
description: "Maximum number of orders to return",
default: 20,
},
},
},
},
{
name: "purchase_create_order",
description: "Create a new purchase order",
inputSchema: {
type: "object",
properties: {
partner_id: {
type: "number",
description: "Vendor partner ID",
},
lines: {
type: "array",
items: {
type: "object",
properties: {
product_id: { type: "number" },
quantity: { type: "number" },
price_unit: { type: "number" },
},
},
description: "Order lines with product, quantity, and price",
},
},
required: ["partner_id", "lines"],
},
},
];
// Tool handlers
async function handleTool(
name: string,
args: Record<string, any>
): Promise<any> {
switch (name) {
// ============ GENERIC HANDLERS ============
case "odoo_search":
return await odoo.search(args.model, args.domain || [], {
limit: args.limit,
order: args.order,
});
case "odoo_read":
return await odoo.read(args.model, args.ids, args.fields);
case "odoo_search_read":
return await odoo.searchRead(args.model, args.domain || [], {
fields: args.fields,
limit: args.limit,
order: args.order,
});
case "odoo_create":
return await odoo.create(args.model, args.values);
case "odoo_update":
return await odoo.write(args.model, args.ids, args.values);
case "odoo_delete":
return await odoo.unlink(args.model, args.ids);
case "odoo_get_fields":
return await odoo.fieldsGet(args.model);
case "odoo_execute":
return await odoo.execute(
args.model,
args.method,
args.args || [],
args.kwargs || {}
);
// ============ INVENTORY HANDLERS ============
case "inventory_list_products": {
const domain: any[] = [];
if (args.category) {
domain.push(["categ_id.name", "ilike", args.category]);
}
if (args.search) {
domain.push("|");
domain.push(["name", "ilike", args.search]);
domain.push(["default_code", "ilike", args.search]);
}
return await odoo.searchRead("product.product", domain, {
fields: [
"name",
"default_code",
"categ_id",
"type",
"list_price",
"qty_available",
"virtual_available",
],
limit: args.limit || 50,
});
}
case "inventory_get_stock": {
const domain: any[][] = [["product_id", "in", args.product_ids]];
if (args.warehouse_id) {
// Get stock location for warehouse
const warehouses = await odoo.read("stock.warehouse", [args.warehouse_id], ["lot_stock_id"]);
if (warehouses.length > 0) {
domain.push(["location_id", "=", warehouses[0].lot_stock_id[0]]);
}
}
return await odoo.searchRead("stock.quant", domain, {
fields: ["product_id", "location_id", "quantity", "reserved_quantity", "lot_id"],
});
}
case "inventory_list_warehouses":
return await odoo.searchRead("stock.warehouse", [], {
fields: ["name", "code", "lot_stock_id", "partner_id"],
});
case "inventory_list_operations": {
const domain: any[][] = [];
if (args.operation_type) {
domain.push(["picking_type_id.code", "=", args.operation_type]);
}
if (args.state) {
domain.push(["state", "=", args.state]);
}
return await odoo.searchRead("stock.picking", domain, {
fields: [
"name",
"partner_id",
"picking_type_id",
"scheduled_date",
"origin",
"state",
],
limit: args.limit || 20,
order: "scheduled_date asc",
});
}
case "inventory_create_transfer": {
// Get internal transfer picking type
const pickingTypes = await odoo.searchRead(
"stock.picking.type",
[["code", "=", "internal"]],
{ fields: ["id"], limit: 1 }
);
if (pickingTypes.length === 0) {
throw new Error("No internal transfer picking type found");
}
const pickingId = await odoo.create("stock.picking", {
picking_type_id: pickingTypes[0].id,
location_id: args.from_location_id,
location_dest_id: args.to_location_id,
scheduled_date: args.scheduled_date || new Date().toISOString().split("T")[0],
});
// Create move lines
for (const product of args.products) {
await odoo.create("stock.move", {
picking_id: pickingId,
product_id: product.product_id,
product_uom_qty: product.quantity,
name: "Transfer",
location_id: args.from_location_id,
location_dest_id: args.to_location_id,
product_uom: 1,
});
}
// Confirm the transfer
await odoo.execute("stock.picking", "action_confirm", [[pickingId]]);
return { id: pickingId, message: "Transfer created and confirmed" };
}
// ============ CRM HANDLERS ============
case "crm_list_leads": {
const domain: any[][] = [];
if (args.stage) {
domain.push(["stage_id.name", "ilike", args.stage]);
}
if (args.user_id) {
domain.push(["user_id", "=", args.user_id]);
}
return await odoo.searchRead("crm.lead", domain, {
fields: [
"name",
"partner_id",
"contact_name",
"email_from",
"phone",
"stage_id",
"expected_revenue",
"user_id",
"create_date",
],
limit: args.limit || 20,
order: "create_date desc",
});
}
case "crm_create_lead": {
const values: Record<string, any> = {
name: args.name,
type: "opportunity",
};
if (args.partner_id) values.partner_id = args.partner_id;
if (args.contact_name) values.contact_name = args.contact_name;
if (args.email_from) values.email_from = args.email_from;
if (args.phone) values.phone = args.phone;
if (args.expected_revenue) values.expected_revenue = args.expected_revenue;
if (args.description) values.description = args.description;
const id = await odoo.create("crm.lead", values);
return { id, message: "Lead created successfully" };
}
// ============ SALES HANDLERS ============
case "sales_list_orders": {
const domain: any[][] = [];
if (args.state) {
domain.push(["state", "=", args.state]);
}
if (args.partner_id) {
domain.push(["partner_id", "=", args.partner_id]);
}
return await odoo.searchRead("sale.order", domain, {
fields: [
"name",
"partner_id",
"date_order",
"amount_total",
"state",
"user_id",
],
limit: args.limit || 20,
order: "date_order desc",
});
}
case "sales_create_order": {
const orderId = await odoo.create("sale.order", {
partner_id: args.partner_id,
note: args.note,
});
// Create order lines
for (const line of args.lines) {
const lineValues: Record<string, any> = {
order_id: orderId,
product_id: line.product_id,
product_uom_qty: line.quantity,
};
if (line.price_unit) {
lineValues.price_unit = line.price_unit;
}
await odoo.create("sale.order.line", lineValues);
}
return { id: orderId, message: "Sales order created successfully" };
}
case "sales_confirm_order":
await odoo.execute("sale.order", "action_confirm", [[args.order_id]]);
return { message: "Order confirmed successfully" };
// ============ CONTACTS HANDLERS ============
case "contacts_list": {
const domain: any[] = [];
if (args.search) {
domain.push("|");
domain.push(["name", "ilike", args.search]);
domain.push(["email", "ilike", args.search]);
}
if (args.is_customer) {
domain.push(["customer_rank", ">", 0]);
}
if (args.is_vendor) {
domain.push(["supplier_rank", ">", 0]);
}
return await odoo.searchRead("res.partner", domain, {
fields: [
"name",
"email",
"phone",
"street",
"city",
"country_id",
"is_company",
"customer_rank",
"supplier_rank",
],
limit: args.limit || 50,
});
}
case "contacts_create": {
const values: Record<string, any> = {
name: args.name,
};
if (args.email) values.email = args.email;
if (args.phone) values.phone = args.phone;
if (args.street) values.street = args.street;
if (args.city) values.city = args.city;
if (args.country_code) {
const countries = await odoo.search("res.country", [
["code", "=", args.country_code.toUpperCase()],
]);
if (countries.length > 0) {
values.country_id = countries[0];
}
}
if (args.is_company !== undefined) values.is_company = args.is_company;
if (args.customer_rank) values.customer_rank = args.customer_rank;
if (args.supplier_rank) values.supplier_rank = args.supplier_rank;
const id = await odoo.create("res.partner", values);
return { id, message: "Contact created successfully" };
}
// ============ MANUFACTURING HANDLERS ============
case "manufacturing_list_orders": {
const domain: any[][] = [];
if (args.state) {
domain.push(["state", "=", args.state]);
}
if (args.product_id) {
domain.push(["product_id", "=", args.product_id]);
}
return await odoo.searchRead("mrp.production", domain, {
fields: [
"name",
"product_id",
"product_qty",
"bom_id",
"state",
"date_start",
"date_finished",
"origin",
],
limit: args.limit || 20,
order: "date_start desc",
});
}
case "manufacturing_create_order": {
const values: Record<string, any> = {
product_id: args.product_id,
product_qty: args.quantity,
};
if (args.bom_id) values.bom_id = args.bom_id;
if (args.scheduled_date) values.date_start = args.scheduled_date;
const id = await odoo.create("mrp.production", values);
// Confirm the MO
await odoo.execute("mrp.production", "action_confirm", [[id]]);
return { id, message: "Manufacturing order created and confirmed" };
}
case "manufacturing_list_boms": {
const domain: any[][] = [];
if (args.product_id) {
domain.push(["product_tmpl_id.product_variant_id", "=", args.product_id]);
}
return await odoo.searchRead("mrp.bom", domain, {
fields: ["product_tmpl_id", "product_qty", "type", "code"],
limit: args.limit || 20,
});
}
// ============ PURCHASE HANDLERS ============
case "purchase_list_orders": {
const domain: any[][] = [];
if (args.state) {
domain.push(["state", "=", args.state]);
}
if (args.partner_id) {
domain.push(["partner_id", "=", args.partner_id]);
}
return await odoo.searchRead("purchase.order", domain, {
fields: [
"name",
"partner_id",
"date_order",
"amount_total",
"state",
"user_id",
],
limit: args.limit || 20,
order: "date_order desc",
});
}
case "purchase_create_order": {
const orderId = await odoo.create("purchase.order", {
partner_id: args.partner_id,
});
// Create order lines
for (const line of args.lines) {
await odoo.create("purchase.order.line", {
order_id: orderId,
product_id: line.product_id,
product_qty: line.quantity,
price_unit: line.price_unit || 0,
name: "Purchase",
date_planned: new Date().toISOString(),
product_uom: 1,
});
}
return { id: orderId, message: "Purchase order created successfully" };
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// Create server
const server = new Server(
{
name: "odoo-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Register handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
const result = await handleTool(name, args || {});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${message}`,
},
],
isError: true,
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Odoo MCP Server running on stdio");
}
main().catch(console.error);