diff --git a/server/odoo_mcp.py b/server/odoo_mcp.py index 3e04fc3..51c2c3e 100644 --- a/server/odoo_mcp.py +++ b/server/odoo_mcp.py @@ -709,6 +709,234 @@ def list_task_stages(project_id: int = None) -> list: ["id", "name", "sequence"], limit=50, order="sequence asc") +# ── Project CRUD ────────────────────────────────────────────────────────────── + +@mcp.tool() +def create_project(name: str, description: str = "", user_id: int = None, + date_start: str = "", date: str = "", + privacy_visibility: str = "employees") -> int: + """Create a new project. Returns the new project ID. + privacy_visibility: 'employees' (all employees), 'portal' (employees + invited portal users), + 'followers' (invited internal users only). + Trigger phrases: "create project", "new project", "add a project", "start a project".""" + vals: dict = {"name": name} + if description: vals["description"] = description + if user_id: vals["user_id"] = user_id + if date_start: vals["date_start"] = date_start + if date: vals["date"] = date + vals["privacy_visibility"] = privacy_visibility or "employees" + return _create("project.project", vals) + +@mcp.tool() +def update_project(project_id: int, name: str = "", description: str = "", + user_id: int = None, date_start: str = "", date: str = "", + privacy_visibility: str = "") -> bool: + """Update an existing project's metadata. + privacy_visibility: 'employees', 'portal', 'followers'. + Trigger phrases: "update project", "rename project", "change project owner", + "set project deadline", "edit project".""" + vals: dict = {} + if name: vals["name"] = name + if description: vals["description"] = description + if user_id: vals["user_id"] = user_id + if date_start: vals["date_start"] = date_start + if date: vals["date"] = date + if privacy_visibility: vals["privacy_visibility"] = privacy_visibility + return _write("project.project", project_id, vals) if vals else False + +@mcp.tool() +def archive_project(project_id: int, archive: bool = True) -> bool: + """Archive or unarchive a project (soft delete). archive=True to archive, False to restore. + Trigger phrases: "archive project", "close project", "deactivate project", + "restore project", "reopen project".""" + return _write("project.project", project_id, {"active": not archive}) + + +# ── Milestones ──────────────────────────────────────────────────────────────── + +@mcp.tool() +def list_milestones(project_id: int, include_reached: bool = False) -> list: + """List milestones for a project. + Set include_reached=True to also include already-completed milestones. + Trigger phrases: "list milestones", "project milestones", "show milestones", + "what milestones exist".""" + domain = [["project_id", "=", project_id]] + if not include_reached: + domain.append(["is_reached", "=", False]) + return _search_read("project.milestone", domain, + ["id", "name", "project_id", "deadline", "is_reached", "reached_date"], + limit=100, order="deadline asc") + +@mcp.tool() +def create_milestone(project_id: int, name: str, deadline: str = "") -> int: + """Create a milestone for a project. deadline format: YYYY-MM-DD. Returns milestone ID. + Trigger phrases: "create milestone", "add milestone", "new milestone".""" + vals: dict = {"project_id": project_id, "name": name} + if deadline: vals["deadline"] = deadline + return _create("project.milestone", vals) + +@mcp.tool() +def update_milestone(milestone_id: int, name: str = "", deadline: str = "", + is_reached: bool = None) -> bool: + """Update a milestone's name, deadline, or completion status. + Set is_reached=True to mark it complete, False to reopen it. + Trigger phrases: "update milestone", "mark milestone complete", "change milestone deadline", + "close milestone".""" + vals: dict = {} + if name: vals["name"] = name + if deadline: vals["deadline"] = deadline + if is_reached is not None: vals["is_reached"] = is_reached + return _write("project.milestone", milestone_id, vals) if vals else False + + +# ── Task chatter / notes ────────────────────────────────────────────────────── + +@mcp.tool() +def post_task_message(task_id: int, body: str, + message_type: str = "comment") -> int: + """Post a message or internal note to a task's chatter thread. + message_type: 'comment' (visible to all followers) or 'internal' (internal note only). + Returns the new mail.message ID. + Trigger phrases: "post a note", "log a message", "add a comment to the task", + "internal note on task", "update task thread", "leave a comment".""" + subtype_xmlid = "mail.mt_note" if message_type == "internal" else "mail.mt_comment" + result = _call("project.task", "message_post", [[task_id]], { + "body": body, + "message_type": "comment", + "subtype_xmlid": subtype_xmlid, + }) + if isinstance(result, int): + return result + if isinstance(result, dict): + return result.get("id", 0) + return 0 + +@mcp.tool() +def get_task_messages(task_id: int, limit: int = 20) -> list: + """Get the chatter message history for a task (most recent first). + Trigger phrases: "task messages", "task history", "chatter", "task notes", + "task comments", "what was said on this task".""" + return _search_read("mail.message", + [["model", "=", "project.task"], ["res_id", "=", task_id], + ["message_type", "in", ["comment", "email"]]], + ["id", "author_id", "date", "body", "subtype_id", "message_type"], + limit=limit, order="date desc") + + +# ── Subtasks ────────────────────────────────────────────────────────────────── + +@mcp.tool() +def get_task_subtasks(task_id: int) -> list: + """Get all direct subtasks (child tasks) of a task. + Trigger phrases: "subtasks", "child tasks", "get subtasks", "list subtasks", + "what are the subtasks".""" + return _search_read("project.task", [["parent_id", "=", task_id]], + ["id", "name", "stage_id", "user_ids", "date_deadline", + "priority", "kanban_state", "active"], + limit=100, order="sequence asc") + +@mcp.tool() +def create_subtask(name: str, parent_task_id: int, project_id: int = None, + description: str = "", date_deadline: str = "", + user_ids: list = None) -> int: + """Create a subtask linked to a parent task. + Inherits the parent's project if project_id is omitted. Returns the new subtask ID. + Trigger phrases: "create subtask", "add subtask", "new subtask", "child task".""" + vals: dict = {"name": name, "parent_id": parent_task_id} + if project_id: vals["project_id"] = project_id + if description: vals["description"] = description + if date_deadline: vals["date_deadline"] = date_deadline + if user_ids: vals["user_ids"] = [(6, 0, user_ids)] + return _create("project.task", vals) + + +# ── Archive task ────────────────────────────────────────────────────────────── + +@mcp.tool() +def archive_task(task_id: int, archive: bool = True) -> bool: + """Archive or unarchive a task (soft delete). archive=True to archive, False to restore. + Trigger phrases: "archive task", "close task", "remove task", "restore task", + "deactivate task", "mark task done and archive".""" + return _write("project.task", task_id, {"active": not archive}) + + +# ── Followers ───────────────────────────────────────────────────────────────── + +@mcp.tool() +def add_task_follower(task_id: int, partner_ids: list = None, + user_ids: list = None) -> bool: + """Subscribe followers to a task so they receive update notifications. + Pass partner_ids (res.partner record IDs) or user_ids (res.users record IDs). + Trigger phrases: "add follower", "subscribe to task", "watch task", + "notify user on task", "follow this task".""" + if user_ids and not partner_ids: + users = _search_read("res.users", [["id", "in", user_ids]], + ["partner_id"], limit=len(user_ids) + 5) + partner_ids = [u["partner_id"][0] for u in users if u.get("partner_id")] + if not partner_ids: + return False + _call("project.task", "message_subscribe", [[task_id]], {"partner_ids": partner_ids}) + return True + +@mcp.tool() +def remove_task_follower(task_id: int, partner_ids: list = None, + user_ids: list = None) -> bool: + """Unsubscribe followers from a task. + Pass partner_ids (res.partner record IDs) or user_ids (res.users record IDs). + Trigger phrases: "remove follower", "unsubscribe from task", "stop following task", + "mute task notifications".""" + if user_ids and not partner_ids: + users = _search_read("res.users", [["id", "in", user_ids]], + ["partner_id"], limit=len(user_ids) + 5) + partner_ids = [u["partner_id"][0] for u in users if u.get("partner_id")] + if not partner_ids: + return False + _call("project.task", "message_unsubscribe", [[task_id]], {"partner_ids": partner_ids}) + return True + + +# ── Stage management ────────────────────────────────────────────────────────── + +@mcp.tool() +def create_task_stage(name: str, project_ids: list, sequence: int = 10, + description: str = "") -> int: + """Create a new task stage and link it to one or more projects. + project_ids: list of project.project IDs to attach this stage to. + Returns the new stage ID. + Trigger phrases: "create stage", "add stage", "new task stage", "add column", + "add kanban column".""" + vals: dict = { + "name": name, + "project_ids": [(6, 0, project_ids)], + "sequence": sequence, + } + if description: vals["description"] = description + return _create("project.task.type", vals) + +@mcp.tool() +def update_task_stage(stage_id: int, name: str = "", sequence: int = None, + add_project_ids: list = None, + remove_project_ids: list = None) -> bool: + """Update a task stage's name, sequence order, or project associations. + add_project_ids: project IDs to link this stage to (appends, does not replace). + remove_project_ids: project IDs to detach this stage from. + Trigger phrases: "rename stage", "reorder stage", "update stage", "move stage", + "change stage order".""" + vals: dict = {} + if name: vals["name"] = name + if sequence is not None: vals["sequence"] = sequence + commands = [] + if add_project_ids: + for pid in add_project_ids: + commands.append((4, pid)) # link without replacing others + if remove_project_ids: + for pid in remove_project_ids: + commands.append((3, pid)) # unlink without deleting + if commands: + vals["project_ids"] = commands + return _write("project.task.type", stage_id, vals) if vals else False + + # ════════════════════════════════════════════════════════════════════════════ # HELPDESK # ════════════════════════════════════════════════════════════════════════════