כתיבת כלי MCP לאתר

איך מוסיפים כלי MCP ייעודי לאתר עם AGENT_TOOLS::add_tool, חוזה ה-handler, בדיקת הרשאות, כלי קריאה וכתיבה, ובדיקה ב-tools/list.

⏱ 7 דק' קריאה 1269 מילים ערוך דף זה ב-GitHub

מעבר לכלים המובנים, אתר יכול לפרסם כלי MCP משלו: שיבוץ כתבה בדף הבית, חישוב מחיר, שליחת לקוח למערכת חיצונית. הכלי נרשם ב-AGENT_TOOLS::add_tool ונגיש גם ללקוחות MCP וגם לסוכני AI, כולל dry-run, ledger ותור אישורים. בדף הזה תכתבו כלי קריאה וכלי כתיבה מלאים ותבדקו אותם.

התיקייה היחידה שנטענת
AGENT_TOOLS::boot() עושה require_once לכל קובץ application/admin/includes/*.php (נתיב יחסי לשורש האתר), כל קובץ בתוך try/catch נפרד. שגיאה בקובץ אחד נרשמת ב-error_log ולא מפילה את האחרים. אין קונבנציה של application/ai_tools/ לכלי MCP: התיקייה הזו שייכת למנגנון ה-cron הישן.

החוזה של add_tool#

חתימה: AGENT_TOOLS::add_tool(array $def). הפונקציה זורקת Exception על הגדרה לא חוקית. ב-boot() החריגה נתפסת, והכלי פשוט לא מופיע.

מפתחחובהכללים
nameכן^[a-z0-9_]{2,40}$, ולא אחד מהשמות השמורים ב-AGENT_TOOLS::RESERVED
labelלאשם בעברית ל-UI ולציר הזמן של הריצה. ברירת מחדל: ה-name
descriptionכןהטקסט שהמודל קורא כדי להחליט מתי להשתמש בכלי. נחתך ל-1000 תווים בפרסום
inputלאמפת properties שטוחה של JSON Schema. מילות המפתח format, examples, $schema, contentEncoding, contentMediaType מוסרות אוטומטית
requiredלארשימת שמות שדות
writeלאtrue: ב-dry-run הכלי מדומה, ב-approve_writes הוא נכנס לתור, והקריאה נרשמת ב-ledger
_metaלאמפה עם מפתחות מחרוזת בלבד, מתפרסמת כפי שהיא לצד inputSchema. המפתח openai/fileParams חייב להצביע על שמות מתוך input
handlerכןcallable בחתימה function(array $args, array $ctx)

ה-handler מקבל $ctx עם admin_id, agent_id, run_id ו-tools (מופע admin_mcp_tools). ערך ההחזרה:

  • מערך הופך ל-data של התשובה.
  • ערך סקלרי הופך ל-["result" => ...].
  • מערך עם המפתח __error או throw הם כישלון. ההודעה חוזרת ללקוח.

השם מתפרסם ב-tools/list כ-<name>, וברשימת הכלים של סוכן הוא נרשם כ-cms:<name>.

שמות שמורים
RESERVED מכיל רק 11 שמות. כלי ליבה כמו form_action, db_schema, update_playbook, generate_image, import_media, delete_media, search_media, כלי seo_* וקידומות של מוצרים (kama_, seok_ ועוד) לא חסומים בקוד, אבל כלי שיצטרף איתם לאותו שם יתנגש. בחרו שם עם קידומת של האתר, למשל hp_ או shop_.

דוגמה מלאה#

הקובץ application/admin/includes/mcp_tools.php. הטבלה hp_slots היא טבלת דוגמה של האתר (קוד סלוט, כותרת, מזהה כתבה), לא טבלה של הליבה:

<?php
/*
 * כלי MCP של האתר. הקובץ נטען גם בבקשות /system/mcp ו-/system/agent_mcp
 * (לא רק בעמודי אדמין), לכן: אין פלט, אין תלות ב-$_GET, אין מחלקות FormInput_*.
 */
if (!class_exists("AGENT_TOOLS")) return;

// 1. כלי קריאה: הסלוטים של דף הבית
AGENT_TOOLS::add_tool([
    "name"        => "hp_get",
    "label"       => "סלוטים בדף הבית",
    "description" => "List the homepage slots (code, title, current article id). Call before hp_place.",
    "input"       => (object)[],
    "handler"     => function (array $args, array $ctx) {
        return ["slots" => DB::get_all("SELECT code, title, article_id FROM CRM_hp_slots ORDER BY ord")];
    },
]);

// 2. כלי כתיבה: שיבוץ כתבה בסלוט, עם בדיקת הרשאה ואימות קלט
AGENT_TOOLS::add_tool([
    "name"        => "hp_place",
    "label"       => "שיבוץ בדף הבית",
    "description" => "Place an article in a homepage slot. Use hp_get first to learn the slot codes. Fails if the article is not published.",
    "input"       => [
        "item_id"  => ["type" => "integer", "description" => "the article id"],
        "position" => ["type" => "string",  "description" => "slot code from hp_get"],
    ],
    "required"    => ["item_id", "position"],
    "write"       => true,   // dry-run: מדומה. approve_writes: נכנס לתור
    "handler"     => function (array $args, array $ctx) {
        // 1. הרשאה: הכלי רץ בשם $ctx["admin_id"]. ADMIN::has_perms קורא את ה-session
        //    הנוכחי ולכן לא מתאים כאן, בודקים את הקבוצה של המנהל ישירות.
        $admin = DB::get_row("SELECT group_id FROM CRM_adminPanel_admins WHERE id = " . (int)$ctx["admin_id"]);
        $group = $admin ? DB::get_row("SELECT perms FROM CRM_adminPanel_admins_groups WHERE id = " . (int)$admin["group_id"]) : false;
        $perms = $group ? (string)$group["perms"] : "";
        $panel = DB::get_val("adminPanel_panels", ["panel_name" => "homepage"]);
        $ids   = array_column((array)json_decode($perms, true), "id");
        $allowed = ($perms === "-1") || ($panel && in_array((int)$panel["id"], array_map("intval", $ids), true));
        if (!$allowed) return ["__error" => "the admin running this tool has no permission on the homepage panel"];

        // 2. אימות קלט
        $id  = (int)($args["item_id"] ?? 0);
        $pos = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($args["position"] ?? "")));
        if ($id <= 0 || $pos === "") return ["__error" => "item_id and position are required"];

        $article = DB::get_val("articles", $id);
        if (!$article || (int)$article["is_active"] !== 1) return ["__error" => "article #$id is not published"];

        $slot = DB::get_val("hp_slots", ["code" => $pos]);
        if (!$slot) return ["__error" => "unknown slot '$pos' (see hp_get)"];

        // 3. כתיבה: update לפי מזהה השורה
        $up = DB::update("hp_slots");
        $up->set_var("article_id", $id);
        $up->set_var("placed_by", (int)$ctx["admin_id"]);
        $up->set("placed_at", "FUNC(NOW())");
        $up->update((int)$slot["id"]);

        // אפשר להפעיל כלי ליבה מתוך הכלי שלכם (אותו הקשר, אותו dry-run ו-ledger):
        // $ctx["tools"]->call("notify_admin", ["message" => "שובצה כתבה $id בסלוט $pos"]);

        return ["placed" => true, "slot" => $pos, "article_id" => $id];
    },
]);

כמה נקודות בדוגמה:

  • ה-handler מקבל קלט מהמודל, ולכן מנקים ובודקים אותו כמו כל קלט חיצוני. בשאילתת SQL גולמית משתמשים ב-CRM_ מלא וב-DB::escape. בבונה (DB::get_val, DB::update) כותבים שם טבלה בלי CRM_.
  • set_var ו-set בונים את העדכון, ו-update($id) מריץ אותו ומחזיר bool. הערך "FUNC(NOW())" ב-set נשלח ל-SQL כפונקציה ולא כמחרוזת.
  • אל תשתמשו ב-DB::insert: הוא לא קיים. הוספה נעשית ב-DB::update("table")->set_var(...)->insert().
הכלי רץ בשם מנהל, אבל בלי פאנל

כלי מובנה כמו save_record עובר דרך הפאנל, והפאנל אוכף הרשאות. ה-handler שלכם ניגש ל-DB ישירות, ולכן אתם אחראים לבדוק הרשאות. אם הכלי משפיע על מטמון, פתחו אותו מחדש בקוד שלכם (ראו cache_engine API).

הפעלת כלי ליבה מתוך כלי#

$ctx["tools"] הוא מופע admin_mcp_tools באותו הקשר (אותו מנהל, ריצה, dry-run). קריאה ל-->call("save_record", [...]) עוברת דרך הפאנל והרשאותיו ומחזירה ["ok"=>..., "data"=>...]. בצורה כזאת כלי אחד יכול להרכיב פעולות מובנות בלי לשכפל ולידציה.

בדיקה#

  1. שלחו tools/list ובדקו ש-hp_get ו-hp_place מופיעים (ראו MCP ב-WIZZO CMS).
  2. הפעילו את הכלי עם __dry_run: true ובדקו שכלי עם write => true לא משנה נתונים.
  3. הוסיפו לסוכן את cms:hp_place ב-סוכני AI.
{
  "jsonrpc": "2.0", "id": 4, "method": "tools/call",
  "params": { "name": "hp_place", "arguments": { "item_id": 1234, "position": "top", "__dry_run": true } }
}
כשהכלי לא מופיע

שם לא חוקי, תיאור ריק או handler שאינו callable זורקים חריגה שנתפסת ב-boot(). בדקו את error_log של האתר: השורה מתחילה ב-AGENT_TOOLS boot:.

ראו גם#

מצאתם טעות או חוסר? תקנו את הדף או פתחו Issue בריפו. התיעוד נכתב מתוך הקוד של ליבה 5.0.115.