Client Features ו-Multi Round-Trip Requests

הבעיה: שרת שצריך משהו באמצע בקשה

לפעמים שרת מגלה, באמצע טיפול בבקשה, שחסר לו משהו שרק המשתמש או ה-host יכולים לספק: אישור לפני פעולה הרסנית, פרט שהמודל לא ידע, או חיבור לחשבון בשירות חיצוני. עד 2025-11-25 הפתרון היה פשוט: השרת שלח בקשת JSON-RPC משלו ל-client על אותו חיבור, חיכה לתשובה והמשיך.

ב-2026-07-28 זה כבר לא אפשרי. אין session, ושרת לא שולח בקשות בכלל (פרק 4). ויותר מזה, בגלל statelessness הבקשה הבאה של ה-client עשויה להגיע לעותק אחר של השרת. שרת שמחכה באמצע פונקציה לתשובה היה צריך אחסון משותף בין העותקים או load balancer עם sticky sessions, בדיוק מה שה-statelessness נועד לחסוך.

הפתרון נקרא Multi Round-Trip Requests (MRTR). במקום לשאול, השרת עונה: הוא מחזיר תשובת ביניים שאומרת מה חסר. ה-client אוסף את המידע ושולח את הבקשה המקורית שוב, הפעם עם התשובות. כל אחת מהבקשות עומדת בפני עצמה, והעותק שמטפל בניסיון החוזר לא צריך לדעת שום דבר מלבד מה שיש בבקשה.

ה-spec מגדיר את זה כשינוי שובר: שרת חייב להשתמש ב-MRTR לכל בקשה ל-client, והדפוס הישן של בקשות ביוזמת השרת כבר לא נתמך בגרסה הזו.

שני תרשימי רצף. משמאל, 2025-11-25: קריאה עם id:1 פותחת פס כתום עם מנעול בצד השרת, השרת שולח elicitation/create ל-client, המשתמש עונה, והתוצאה חוזרת. כל זה על אותו שרת. מימין, 2026-07-28: קריאה עם id:1 מקבלת input_required ונסגרת, ואחרי המשתמש יוצאת בקשה חדשה עם id:2 לעותק אחר של השרת, שמחזיר תוצאה.
משמאל: השרת שולח בקשה באמצע קריאה פתוחה. מימין (MRTR): תשובת ביניים ושליחה חוזרת כבקשה חדשה

InputRequiredResult — תשובת הביניים

תשובת ביניים היא result רגיל עם resultType של input_required (פרק 2) ושני שדות אופציונליים. inputRequests הוא מפה ממפתח שהשרת בחר לבקשה מוטמעת, שהיא אחת משלוש: elicitation/create, sampling/createMessage או roots/list. requestState הוא מחרוזת אטומה שרק השרת מבין (בסעיף נפרד). השרת חייב לכלול לפחות אחד מהשניים.

הדוגמה היא תשובה אמיתית של שרת ה-notes שנבנה בפרק 8, לקריאה ל-delete_note: השרת מבקש מהמשתמש אישור במצב form, עם סכמה של שדה boolean אחד. שימו לב שזו תשובה לבקשה עם id 3, והיא סוגרת אותה. הבקשה הבאה תהיה בקשה חדשה.

MRTR מותר רק בתשובה לשלוש בקשות: tools/call, resources/read ו-prompts/get. על כל בקשה אחרת אסור להחזיר input_required. ושרת לא יכול לבקש משהו שה-client לא הצהיר עליו: אם ב-clientCapabilities אין elicitation, אסור לכלול elicitation/create. ה-SDK הרשמי אוכף את זה ומחזיר במקרה כזה -32021 (MissingRequiredClientCapability), כפי שראינו בפרק 3.

JSON
{
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "confirm": {
        "method": "elicitation/create",
        "params": {
          "message": "Delete the note \"Q3 planning\"? This cannot be undone.",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "confirm": {
                "type": "boolean",
                "title": "Delete permanently"
              }
            },
            "required": [
              "confirm"
            ]
          },
          "mode": "form"
        }
      }
    },
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "notes-server",
        "version": "1.0.0"
      }
    }
  },
  "jsonrpc": "2.0",
  "id": 3
}

הניסיון החוזר: inputResponses ו-requestState

ה-client אוסף את התשובות ושולח את הבקשה המקורית שוב: אותו method ואותם params, ובנוסף inputResponses, מפה עם אותם מפתחות כמו inputRequests. הערכים הם התוצאות: ElicitResult ל-elicitation, CreateMessageResult ל-sampling, ListRootsResult ל-roots. בדוגמה המשתמש אישר את המחיקה, והשרת מחזיר תשובה סופית עם resultType של complete.

כללים ל-client: אם יש inputRequests, הוא חייב להשלים אותן לפני הניסיון החוזר. אם יש requestState, הוא חייב להחזיר אותו בדיוק כפי שקיבל, בלי לפענח או לשנות. אם אין, אסור לו להמציא. ה-id של הניסיון החוזר חייב להיות חדש, כי זו בקשה עצמאית. ו-inputResponses ו-requestState שייכים רק לניסיון החוזר הזה, לא לבקשות אחרות שרצות במקביל.

כללים לשרת: הוא לא יכול להניח שה-client ישלים את הבקשות או ינסה שוב. אם בניסיון החוזר חסר מידע שנדרש, השרת צריך (SHOULD) להחזיר InputRequiredResult חדש ולא שגיאה, ופרמטרים לא צפויים ב-inputResponses פשוט מתעלמים מהם. בנוסף, תשובות לניסיונות חוזרים לא נשמרות ב-cache (פרק 5).

JSON
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "delete_note",
    "arguments": {
      "id": "note_9683254a-ea4c-4c86-95e2-652dc2ff29e1"
    },
    "inputResponses": {
      "confirm": {
        "action": "accept",
        "content": {
          "confirm": true
        }
      }
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {
        "elicitation": {
          "form": {}
        }
      }
    }
  }
}

requestState — מצב שעובר דרך ה-client

requestState הוא מה שמאפשר לשרת להיות stateless גם כשהתהליך נמשך כמה סבבים. השרת מקודד לתוכו את מה שהוא צריך לזכור (למשל, מה כבר חושב או לאן הפנה את המשתמש), וה-client מחזיר אותו בניסיון החוזר. הפורמט חופשי: base64 של JSON, JWT מוצפן או בינארי מסודר.

אבל requestState עובר דרך ה-client, ולכן השרת חייב להתייחס אליו כקלט שתוקף שולט בו. אם הוא משפיע על הרשאות, על גישה למשאבים או על לוגיקה עסקית, חייבים להגן על השלמות שלו, למשל ב-HMAC או AEAD, ולדחות מצב שהאימות שלו נכשל. אפשר לוותר על ההגנה רק כששינוי שלו לא יכול לגרום ליותר מכישלון של הבקשה.

כדי למנוע replay, השרת צריך (SHOULD) לכלול בתוך המטען המוגן את זהות המשתמש המאומת, תוקף קצר, ומזהה של הבקשה המקורית (למשל ה-method ו-digest של הפרמטרים המרכזיים), ולבדוק את שלושתם בכל ניסיון חוזר. גם זה לא מבטיח שימוש חד-פעמי. אם ה-state חייב להיות מנוצל פעם אחת בלבד (למשל מימוש של הטבה), צריך לאכוף את זה בצד השרת.

Elicitation במצב form

Elicitation הוא הדרך של שרת לבקש מידע מהמשתמש. client שתומך בזה מצהיר ב-clientCapabilities על elicitation, עם form, url או שניהם. elicitation עם אובייקט ריק שקול ל-form בלבד, לשם תאימות לאחור. לכל בקשה יש mode ו-message שמסביר למשתמש למה המידע נדרש. mode חסר נחשב form.

במצב form השרת שולח requestedSchema, תת-קבוצה מוגבלת של JSON Schema, כדי שה-client יוכל לבנות ממנה טופס פשוט. הסכמה היא אובייקט שטוח עם שדות פרימיטיביים בלבד: string (עם minLength, maxLength ו-format מתוך email, uri, date ו-date-time), number או integer (עם minimum ו-maximum), boolean, ו-enums. enum של בחירה יחידה נכתב עם enum, או עם oneOf של const ו-title כשרוצים תוויות. בחירה מרובה היא array של enum או של anyOf. לכל שדה אפשר default, ו-client שתומך בזה צריך למלא אותו מראש.

אובייקטים מקוננים, מערכים של אובייקטים ושאר היכולות המתקדמות של JSON Schema לא נתמכים, בכוונה. זה טופס למשתמש, לא API. הדוגמה היא בקשה מוטמעת שמבקשת פרטי משלוח.

JSON
{
  "method": "elicitation/create",
  "params": {
    "mode": "form",
    "message": "Where should we ship the order?",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "title": "Full name",
          "minLength": 2
        },
        "email": {
          "type": "string",
          "format": "email",
          "title": "Email"
        },
        "shipping": {
          "type": "string",
          "title": "Shipping speed",
          "oneOf": [
            {
              "const": "std",
              "title": "Standard (5 days)"
            },
            {
              "const": "exp",
              "title": "Express (1 day)"
            }
          ],
          "default": "std"
        },
        "gift": {
          "type": "boolean",
          "title": "Gift wrap",
          "default": false
        }
      },
      "required": [
        "name",
        "email"
      ]
    }
  }
}

accept, decline ו-cancel

לתשובת elicitation יש action עם אחד משלושה ערכים. accept: המשתמש אישר ושלח, ובמצב form ה-content מכיל את הנתונים לפי הסכמה. decline: המשתמש סירב במפורש, למשל לחץ על דחייה. cancel: המשתמש סגר את החלון בלי להחליט, לחץ Escape, או שהדפדפן לא נטען.

ההבחנה בין decline ל-cancel חשובה לשרת: אחרי decline כדאי להציע חלופה או לוותר, ואחרי cancel אפשר לשאול שוב מאוחר יותר. השרת חייב (MUST) לטפל בשני המקרים. טעות נפוצה היא לבדוק רק אם יש תשובה מאושרת, ולשאול שוב על כל דבר אחר. כך משתמש שסירב נשאל שוב ושוב. ב-delete_note בפרק 8 נראה איך להבדיל בין אין תשובה עדיין לבין תשובה שלילית.

הדוגמה היא inputResponses עם שתי תשובות: פרטי משלוח שאושרו, והרשמה לניוזלטר שנדחתה. ה-client צריך לאמת את התשובה מול הסכמה לפני השליחה, והשרת צריך לאמת שוב כשהיא מגיעה, כי היא קלט מה-client.

JSON
{
  "address": {
    "action": "accept",
    "content": {
      "name": "Dana Levi",
      "email": "dana@example.com",
      "shipping": "exp",
      "gift": false
    }
  },
  "newsletter": {
    "action": "decline"
  }
}

Elicitation במצב url

יש מידע שאסור שיעבור דרך ה-client או דרך המודל: סיסמאות, מפתחות API, פרטי תשלום, או התחברות OAuth לשירות צד שלישי. לשם כך יש מצב url. השרת שולח url ו-message, וה-client מציע למשתמש לפתוח את הכתובת בדפדפן. המשתמש מבצע את הפעולה ישירות מול השרת, והמידע לא עובר ב-MCP בכלל.

accept במצב url פירושו שהמשתמש הסכים לפתוח את הכתובת, לא שהפעולה הסתיימה. ה-client שולח את הבקשה שוב, והשרת בודק לפי requestState (או לפי מצב שהוא שומר) אם הפעולה החיצונית הושלמה. אם כן, הוא מחזיר תוצאה סופית. אם לא, הוא מחזיר InputRequiredResult נוסף. ה-client צריך לתת למשתמש דרך ידנית לנסות שוב או לבטל.

כללים ל-client: אסור לטעון את ה-URL מראש, אסור לפתוח אותו בלי הסכמה מפורשת, חובה להציג את הכתובת המלאה לפני ההסכמה, וחובה לפתוח אותה בדרך שלא מאפשרת ל-client או למודל לראות את התוכן או את מה שהמשתמש מקליד (דפדפן מערכת, לא WebView מוטמע). רצוי להדגיש את הדומיין ולהזהיר מפני כתובות חשודות, כמו Punycode. כללים לשרת: אסור לכלול בכתובת מידע אישי או credentials, ואסור לשלוח כתובת שכבר מאומתת לגישה למשאב מוגן.

חשוב גם מה url mode לא עושה: הוא לא נועד לאימות של ה-client מול השרת עצמו. זה תפקיד ה-Authorization של MCP (פרק 10). הוא מיועד למצב שבו השרת עצמו צריך הרשאה משירות אחר בשם המשתמש, ואז השרת שומר את ה-tokens של הצד השלישי אצלו ואף פעם לא מעביר אותם ל-client. והשרת חייב לוודא שהמשתמש שמשלים את התהליך בדפדפן הוא אותו משתמש שהתחיל אותו. אחרת תוקף יכול לשלוח את הקישור לקורבן ולקבל גישה לחשבון שלו.

JSON
{
  "jsonrpc": "2.0",
  "id": 41,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_auth": {
        "method": "elicitation/create",
        "params": {
          "mode": "url",
          "url": "https://mcp.example.com/connect/github",
          "message": "Connect your GitHub account so the server can open pull requests for you."
        }
      }
    },
    "requestState": "v1.eyJvcCI6Im9wZW5fcHIiLCJleHAiOjE3OTA0MTIwMDB9.hmac-sha256-signature"
  }
}
חמישה שלבים ממוספרים. 1: ה-host מציג קישור למשתמש. 2: המשתמש פותח אותו בדפדפן. 3: הדפדפן מגיע לדף החיבור של שרת ה-MCP. 4: הדפדפן עובר לשירות צד שלישי (ענן עם מפתח). 5: token חוזר לשרת ונשמר במסד הנתונים שלו. קו כתום מקווקו עם איקס בין השרת ל-host מראה שה-token לא מגיע ל-client.
elicitation במצב url: ההרשאה מול הצד השלישי קורית בדפדפן, וה-token נשמר בשרת בלבד

כללי אבטחה ל-elicitation

הכלל הראשון: שרת לא יכול (MUST NOT) לבקש מידע רגיש במצב form: סיסמאות, מפתחות API, access tokens או פרטי תשלום. בשביל אלה יש url mode. מידע כמו שם, כתובת מייל או שם משתמש לא אסור, אבל ההחלטה בידי השרת, והמשתמש תמיד יכול לראות ולסרב.

ה-client חייב להבהיר ב-UI איזה שרת מבקש את המידע, לאפשר למשתמש לבדוק ולשנות את התשובות לפני השליחה, ולתת אפשרות ברורה לסרב או לבטל. זה חשוב במיוחד כשמחוברים כמה שרתים: המשתמש צריך לדעת שהבקשה מגיעה משרת ה-notes, ולא מהאפליקציה עצמה.

השרת, מצידו, חייב לקשור כל בקשת elicitation לזהות ה-client והמשתמש, ולא להסתמך על זהות שה-client מצהיר עליה. משתמש שכותב בטופס שהוא joe@example.com לא הוכיח שום דבר. הזהות האמינה מגיעה מה-Authorization (פרק 10), למשל מה-claim של sub ב-token.

Sampling ו-Roots — deprecated

שתי יכולות client נוספות קיימות עדיין בסכמה, אבל הוגדרו deprecated ב-2026-07-28. Sampling אפשר לשרת לבקש מה-host להריץ את ה-LLM בשבילו, עם הודעות, system prompt ו-maxTokens, כך שהשרת קיבל יכולת של מודל בלי מפתח API משלו. Roots אפשר לשרת לשאול את ה-client על תיקיות העבודה של המשתמש.

לפי ה-spec, שתיהן נשארות פעילות לפחות עד הגרסה הראשונה שתצא ב-2027-07-28 או אחריה, ובזמן הזה הן עוברות דרך MRTR, כבקשות מוטמעות sampling/createMessage ו-roots/list (הדוגמה). מימושים חדשים לא אמורים להוסיף תמיכה בהן.

המעבר המומלץ: במקום Sampling, השרת מתחבר ישירות ל-API של ספק מודל. במקום Roots, הנתיבים עוברים כפרמטרים של כלים, כ-URIs של resources, או בקונפיגורציה של השרת. גם הערך של includeContext ב-sampling (thisServer או allServers) deprecated, והשדה צריך להיות חסר או none.

JSON
{
  "summarize": {
    "method": "sampling/createMessage",
    "params": {
      "messages": [
        {
          "role": "user",
          "content": {
            "type": "text",
            "text": "Summarize this changelog in one sentence."
          }
        }
      ],
      "systemPrompt": "You are a concise technical writer.",
      "maxTokens": 100
    }
  }
}

Logging — deprecated, ואיך הוא עובד היום

גם Logging, היכולת של שרת לשלוח הודעות לוג מובנות ל-client, deprecated ב-2026-07-28. המעבר המומלץ הוא stderr בשרתי stdio (פרק 4) ו-OpenTelemetry לתצפית מובנית.

בתקופת המעבר, הלוג עובד לפי בקשה. ה-client מבקש לוגים על בקשה מסוימת באמצעות io.modelcontextprotocol/logLevel ב-_meta, עם אחת מרמות RFC 5424: debug, info, notice, warning, error, critical, alert או emergency. השרת לא יכול (MUST NOT) לשלוח notifications/message לבקשה שלא ביקשה. ההודעות עוברות רק על ה-stream של אותה בקשה, לפני התשובה הסופית, ואף פעם לא על stream של subscription. רמה לא מוכרת מחזירה -32602.

הבקשה logging/setLevel מ-2025-11-25, שקבעה רמת לוג לכל ה-session, הוסרה. ובכל מקרה, הודעת לוג לא יכולה להכיל credentials, מידע אישי או פרטים פנימיים שיכולים לעזור לתוקף.

JSON
{
  "jsonrpc": "2.0",
  "method": "notifications/message",
  "params": {
    "level": "warning",
    "logger": "notes-db",
    "data": {
      "message": "Search index is stale, falling back to a full scan"
    }
  }
}

בגרסה הקודמת: בקשות ביוזמת השרת

בגרסה הקודמת של הפרוטוקול, 2025-11-25, שרת שהיה צריך מידע שלח בקשת JSON-RPC רגילה ל-client, עם id משלו, על החיבור הפתוח: elicitation/create, sampling/createMessage או roots/list. ב-HTTP היא עברה על ה-SSE, וב-stdio על stdout. ה-client ענה ב-JSON-RPC response, והשרת המשיך מאותה נקודה בקוד, כשכל ההקשר עדיין בזיכרון.

במצב url היה מנגנון נוסף: השרת יכול היה להחזיר שגיאה -32042 (URL elicitation required) עם elicitationId, ולהודיע על סיום עם notifications/elicitation/complete. ב-2026-07-28 שלושתם הוסרו. ה-client לומד מה קרה באמצעות ניסיון חוזר, ושרת שצריך לקשר בין סבבים מקודד מזהה משלו ב-requestState.

הדוגמה היא בקשת elicitation בסגנון הישן, מהשרת ל-client. שימו לב שיש בה id ו-method, כמו כל בקשה, אבל הכיוון הפוך. אם אתם כותבים client שצריך לעבוד גם עם שרתים ישנים, הוא צריך לדעת לענות לבקשות כאלה על ה-session.

JSON
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "elicitation/create",
  "params": {
    "mode": "form",
    "message": "Please provide your GitHub username",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string"
        }
      },
      "required": [
        "name"
      ]
    }
  }
}

סקיצה: לולאת MRTR בצד ה-client

callWithInput עוטפת כל בקשה שעשויה להחזיר input_required. היא שולחת את הבקשה, ואם התשובה היא input_required היא מפעילה handler לכל בקשה מוטמעת לפי ה-method שלה, שולחת את הבקשה המקורית שוב עם inputResponses ועם requestState כפי שהתקבל, ועם id חדש. יש גבול למספר הסבבים, כדי ששרת שמבקש שוב ושוב לא יתקע את ה-client.

ה-handlers הם החלק של ה-host: askUser מציג טופס או קישור, ומחזיר ElicitResult. אם ה-client לא יודע לטפל ב-method מסוים, זו שגיאה אצלו, כי הוא אמור היה לא להצהיר על היכולת מלכתחילה.

בתחתית יש שרת מדומה שמבקש אישור בסבב הראשון ומחזיר תוצאה בשני, ו-handler שמדמה משתמש שאישר. אותו קוד עובד מול השרת האמיתי של פרק 8, שם ה-send שולח את ההודעה ב-transport.

TypeScript
type Json = Record<string, unknown>;
type InputRequest = { method: string; params?: Json };
type RpcResult = Json & { resultType?: string; inputRequests?: Record<string, InputRequest>; requestState?: string };
type Send = (message: { jsonrpc: "2.0"; id: number; method: string; params: Json }) => Promise<RpcResult>;
type InputHandler = (request: InputRequest) => Promise<Json>;

let nextId = 1;

async function callWithInput(
  send: Send,
  method: string,
  params: Json,
  handlers: Record<string, InputHandler>,
  maxRounds = 3,
): Promise<RpcResult> {
  let extra: Json = {};
  for (let round = 0; round <= maxRounds; round++) {
    // כל ניסיון הוא בקשה עצמאית עם id חדש
    const result = await send({ jsonrpc: "2.0", id: nextId++, method, params: { ...params, ...extra } });
    if ((result.resultType ?? "complete") !== "input_required") return result;

    const inputResponses: Json = {};
    for (const [key, request] of Object.entries(result.inputRequests ?? {})) {
      const handler = handlers[request.method];
      if (!handler) throw new Error(`No handler for ${request.method}; do not declare that capability`);
      inputResponses[key] = await handler(request);
    }
    // requestState חוזר בדיוק כפי שהתקבל, ורק אם התקבל
    extra = { inputResponses, ...(result.requestState !== undefined ? { requestState: result.requestState } : {}) };
  }
  throw new Error(`Server still requires input after ${maxRounds} rounds`);
}

// --- הדגמה: שרת מדומה ומשתמש מדומה ---
const fakeServer: Send = async ({ id, params }) => {
  console.log("->", id, JSON.stringify(params));
  const answer = (params.inputResponses as Json | undefined)?.confirm as { action: string; content?: Json } | undefined;
  if (!answer) {
    return {
      resultType: "input_required",
      inputRequests: { confirm: { method: "elicitation/create", params: { mode: "form", message: "Delete note?" } } },
      requestState: "opaque-123",
    };
  }
  const deleted = answer.action === "accept" && answer.content?.confirm === true;
  return { resultType: "complete", content: [{ type: "text", text: deleted ? "Deleted." : "Not deleted." }] };
};

const askUser: InputHandler = async (request) => {
  console.log("   UI:", request.params?.message);
  return { action: "accept", content: { confirm: true } };
};

callWithInput(fakeServer, "tools/call", { name: "delete_note", arguments: { id: "note_1" } }, { "elicitation/create": askUser })
  .then((result) => console.log("<-", JSON.stringify(result)));