Lifecycle, גרסאות ו-Capability Negotiation
אין handshake: כל בקשה עומדת בפני עצמה
בפרק 2 ראינו שכל בקשה נושאת _meta עם גרסה ויכולות. בפרק הזה נבין למה. ב-2026-07-28 אין שלב פתיחה שבו הצדדים מתאמים ביניהם דברים: MCP הוא פרוטוקול stateless, וכל המידע שדרוש כדי לעבד בקשה נמצא בבקשה עצמה.
ה-spec מנסח את זה ככללים מפורשים. שרת לא יכול (MUST NOT) להסתמך על בקשות קודמות באותו חיבור כדי לדעת מה הגרסה, מה היכולות או מי ה-client. הוא צריך להיות מוכן לבקשות שמגיעות משיחות ומשימות שונות על אותו חיבור, ולא לדרוש שבקשות קשורות יגיעו דרך אותו חיבור או תהליך.
המשמעות המעשית: חיבור פתוח, גם תהליך stdio שרץ שעות, הוא לא שיחה ולא session. ה-client רשאי לשלב בו בקשות לא קשורות, והשרת לא אמור להתייחס לזהות החיבור כאל זהות השיחה. גם ה-client לא אמור לקשור את חיי תהליך ה-stdio לשיחה או למשימה בודדת.
זה חל גם על רשימות. תשובה ל-tools/list לא יכולה להשתנות לפי החיבור או כתוצאה מבקשות קודמות. היא כן יכולה להשתנות לפי ההרשאה שמצורפת לבקשה (למשל, להחזיר רק כלים שה-scopes של המשתמש מתירים), כי credentials הם קלט של הבקשה ולא מצב של חיבור.
הדוגמה היא בקשת tools/call מלאה כפי שהיא עוברת ב-wire, עם כל שדות ה-_meta. ה-client מצהיר כאן שהוא תומך ב-elicitation בשני המצבים, form ו-url (פרק 7).
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "create_issue",
"arguments": { "title": "Login fails on Safari" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} }
},
"io.modelcontextprotocol/clientInfo": {
"name": "my-host",
"version": "1.4.0"
}
}
}
}השדות לכל בקשה ולכל תשובה
שני שדות הם חובה בכל בקשה של client: io.modelcontextprotocol/protocolVersion, מחרוזת הגרסה שבה הבקשה כתובה, ו-io.modelcontextprotocol/clientCapabilities, היכולות של ה-client שרלוונטיות לבקשה הזו. בקשה שחסר בה אחד מהם היא malformed, והשרת חייב לדחות אותה בקוד -32602 (Invalid params), וב-HTTP בסטטוס 400.
io.modelcontextprotocol/clientInfo, שם וגרסה של ה-client, הוא אופציונלי, אבל ה-client צריך (SHOULD) לשלוח אותו בכל בקשה. io.modelcontextprotocol/logLevel אופציונלי ומבקש מהשרת הודעות לוג לבקשה הזו (פרק 7).
בכיוון ההפוך, השרת צריך (SHOULD) לשים בכל result את io.modelcontextprotocol/serverInfo. כך כל תשובה מזהה את השרת בלי להסתמך על מצב חיבור.
שימו לב לניסוח: clientCapabilities הן היכולות שרלוונטיות לבקשה הזו. מכיוון שהן נשלחות בכל בקשה מחדש, client יכול להצהיר על יכולות שונות בבקשות שונות. למשל, הרצה ברקע שאין בה משתמש שיוכל לענות על שאלה פשוט לא תכלול elicitation.
server/discover — מה השרת יודע לעשות
server/discover היא ה-method היחיד ששרת חייב (MUST) לממש בכל מקרה. הבקשה לא מכילה פרמטרים מלבד ה-_meta הרגיל, והתשובה מרכזת את כל מה שה-client צריך לדעת על השרת.
שדות התשובה: supportedVersions, רשימת הגרסאות שהשרת תומך בהן, ומתוכה ה-client בוחר גרסה לבקשות הבאות. capabilities, היכולות של השרת (בסעיף הבא). instructions, טקסט אופציונלי בשפה טבעית שמסביר איך להשתמש בשרת היטב. ה-client יכול, למשל, להכניס אותו ל-system prompt, והוא לא אמור לחזור על מה שכבר כתוב בתיאורי הכלים. ובתוך _meta, ה-serverInfo.
התשובה גם cacheable: ttlMs אומר כמה מילישניות מותר לשמור אותה, ו-cacheScope אומר אם מתווכים משותפים (proxy, CDN) רשאים לשמור אותה (public) או רק ה-client עצמו (private).
מתי קוראים לה? היא לא חובה. client יכול לשלוח כל בקשה ישירות ולטפל בשגיאת גרסה אם תגיע. אבל יש לה שני שימושים טובים: להציג את השרת ויכולותיו ב-UI בבקשה אחת, במקום לשלוח tools/list, prompts/list ו-resources/list בנפרד, ולשמש כבדיקת גישוש ב-stdio כדי לזהות שרת בגרסה ישנה (בהמשך הפרק).
{
"jsonrpc": "2.0",
"id": "discover-1",
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28", "2025-11-25"],
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"prompts": {},
"completions": {}
},
"instructions": "Issue tracker for the web team. Search before creating to avoid duplicates.",
"ttlMs": 3600000,
"cacheScope": "public",
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "issues-server",
"version": "0.9.2"
}
}
}
}Capabilities: מה כל צד מצהיר
ServerCapabilities מופיעות בתשובת server/discover. tools, resources ו-prompts מסמנים שהשרת מציע את ה-primitive הזה. בתוכם, listChanged אומר שהשרת ישלח הודעה כשהרשימה משתנה, ו-subscribe (ב-resources בלבד) אומר שאפשר להירשם לעדכונים על resource מסוים. completions מסמן תמיכה בהשלמה אוטומטית של ארגומנטים (פרק 6). logging קיים עדיין אבל deprecated.
ClientCapabilities מופיעות ב-_meta של כל בקשה. elicitation, עם form ו-url כתתי-יכולות, אומר שה-client יודע לשאול את המשתמש (פרק 7). sampling ו-roots קיימים עדיין בסכמה אבל הוגדרו deprecated ב-2026-07-28, ומימושים חדשים לא אמורים להוסיף אותם.
לשני הצדדים יש גם experimental, ליכולות לא סטנדרטיות, ו-extensions, להרחבות רשמיות או של צד שלישי (בסעיף הבא). הרשימה לא סגורה: כל צד רשאי להגדיר יכולות נוספות משלו.
הכלל החשוב: שרת לא יכול (MUST NOT) להסתמך על יכולת שה-client לא הצהיר עליה. אם עיבוד הבקשה דורש יכולת כזו, השרת מחזיר MissingRequiredClientCapabilityError, קוד -32021, ובשדה data.requiredCapabilities מפרט מה חסר. ב-HTTP הסטטוס הוא 400. ה-client יכול להציג למשתמש הודעה ברורה, או לשלוח שוב עם היכולת אם הוא בכל זאת יכול לספק אותה.
{
"jsonrpc": "2.0",
"id": 12,
"error": {
"code": -32021,
"message": "Server requires the elicitation capability for this request",
"data": {
"requiredCapabilities": {
"elicitation": {}
}
}
}
}Extensions — משא ומתן על הרחבות
מעבר לליבה, MCP מגדיר extensions: יכולות אופציונליות שנכנסות רק כששני הצדדים תומכים בהן. הן מוצהרות בשדה extensions בתוך ה-capabilities, כמפה ממזהה ההרחבה לאובייקט הגדרות שלה. אובייקט ריק פירושו תמיכה בלי הגדרות מיוחדות.
מזהה של הרחבה חייב לעמוד בכללי המפתחות של _meta, עם prefix חובה. הרחבות רשמיות משתמשות ב-io.modelcontextprotocol/, למשל io.modelcontextprotocol/tasks, ומוציאים של צד שלישי משתמשים ב-prefix משלהם, כמו com.example/.
אם רק צד אחד תומך בהרחבה, הצד התומך חייב לחזור להתנהגות של הליבה או לדחות את הבקשה בשגיאה מתאימה. הרחבה אמורה (SHOULD) לתעד מה ה-fallback שלה. את ההרחבות הבולטות, Tasks, MCP Apps ו-Skills, נכיר בפרק 10.
{
"capabilities": {
"tools": {},
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}בחירת גרסה וטיפול באי-התאמה
אין משא ומתן מראש: ה-client כותב את הגרסה המועדפת עליו ב-_meta, והשרת מקבל או דוחה כל בקשה בנפרד. אם השרת לא מממש את הגרסה, בין אם הוא לא מכיר אותה ובין אם בחר לא לתמוך בה, הוא חייב להחזיר UnsupportedProtocolVersionError, קוד -32022, עם data.supported (הגרסאות שהוא תומך בהן) ו-data.requested. ב-HTTP הסטטוס הוא 400. ראינו את ה-JSON של השגיאה הזו בפרק 2.
ה-client צריך (SHOULD) לבחור מתוך supported גרסה ששניהם תומכים בה ולשלוח את הבקשה שוב, או להציג למשתמש שגיאה אם אין גרסה משותפת. client זהיר יבחר את הגרסה החדשה ביותר מבין המשותפות, וישמור את הבחירה כדי לא לשלם על הסבב הנוסף בכל בקשה.
ה-spec מגדיר שלושה מונחים שנשתמש בהם מעכשיו. Modern הן גרסאות שבהן הגרסה והיכולות עוברות ב-_meta של כל בקשה (2026-07-28 ואילך). Legacy הן גרסאות עם handshake של initialize (2025-11-25 ומוקדמות יותר). Dual-era הוא מימוש שתומך בשני הסוגים.
למה stateless — ואיך שומרים מצב בכל זאת
הסיבה העיקרית היא סקייל ופשטות תפעולית. כששרת לא מחזיק session, כל בקשה יכולה להגיע לכל עותק שלו: אפשר להריץ אותו מאחורי load balancer רגיל, בכמה עותקים או כ-serverless function, בלי sticky sessions ובלי מאגר sessions משותף.
אבל לפעמים יש מצב אמיתי שחייב לשרוד בין קריאות: עגלת קניות, דפדפן פתוח, טרנזקציה במסד נתונים. ה-spec קובע שמצב כזה חייב (MUST) להיות מוחזק מאחורי מזהה מפורש שה-client מעביר בכל בקשה. בפועל, השרת מחזיר handle מכלי שיוצר את המצב ומקבל אותו כארגומנט רגיל בקריאות הבאות. מבחינת ה-wire, handle הוא סתם מחרוזת. המודל הוא שמעביר אותה הלאה מקריאה לקריאה.
כמה כללים לתכנון handles, לפי ההנחיות ב-spec. הרשאה: בשרת עם אימות, handle הוא שם ולא הרשאה, ולכן בכל קריאה בודקים שלמבקש מותר לגשת אליו. בשרת בלי אימות ה-handle הוא בעצם bearer token, ולכן מייצרים אותו עם מספיק אקראיות (למשל UUIDv4) ונותנים לו תוקף מוגבל. אטימות: handle שמקודד מבנה פנימי מזמין ניחושים. אורך חיים: כתבו את מדיניות התפוגה בתיאור הכלי שיוצר אותו, כדי שהמודל יראה אותה. ותפוגה: קריאה עם handle שפג מחזירה שגיאת ביצוע (isError) שאומרת את זה, כדי שהמודל ייצור handle חדש.
{
"jsonrpc": "2.0",
"id": 21,
"result": {
"resultType": "complete",
"content": [
{ "type": "text", "text": "Created basket bsk_7f3a9c2e. Baskets expire after 24 hours of inactivity." }
],
"structuredContent": { "basket_id": "bsk_7f3a9c2e" }
}
}
בגרסה הקודמת: initialize
בגרסה הקודמת של הפרוטוקול, 2025-11-25, כל חיבור התחיל ב-handshake של שלושה שלבים, וזה השינוי הגדול ביותר בין הגרסאות. כדאי להכיר אותו היטב, כי הוא מה שתראו ברוב השרתים והמדריכים הקיימים.
בשלב הראשון, ה-client שולח בקשת initialize, שחייבת להיות האינטראקציה הראשונה. ב-params יש protocolVersion (הגרסה העדכנית ביותר שה-client תומך בה), capabilities ו-clientInfo. אלה אותם נתונים שב-2026-07-28 עוברים ב-_meta של כל בקשה, אבל כאן הם נשלחים פעם אחת בלבד, כשדות רגילים ב-params.
שימו לב גם ליכולות שהיו אז: roots עם listChanged, sampling, elicitation, ו-tasks, שהיה אז חלק מהליבה ועבר ב-2026-07-28 להרחבה.
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {},
"elicitation": { "form": {}, "url": {} }
},
"clientInfo": {
"name": "ExampleClient",
"title": "Example Client Display Name",
"version": "1.0.0"
}
}
}
בגרסה הקודמת: תשובת initialize ו-session
בשלב השני השרת עונה עם protocolVersion, capabilities, serverInfo ו-instructions. גם המשא ומתן על הגרסה היה שונה: אם השרת תמך בגרסה שה-client ביקש, הוא החזיר אותה. אחרת, הוא החזיר גרסה אחרת שהוא תומך בה (רצוי העדכנית ביותר), ואם ה-client לא תמך בה הוא היה אמור להתנתק.
בשלב השלישי ה-client שולח notification בשם notifications/initialized, והחיבור עובר לשלב הפעולה. עד אז, שני הצדדים לא היו אמורים לשלוח בקשות מלבד ping (ובצד השרת, גם הודעות לוג). מרגע זה, הגרסה והיכולות שהוסכמו תקפות לכל ה-session, ושני הצדדים חייבים להשתמש רק ביכולות שהוסכמו.
ב-HTTP, השרת יכול היה להקצות session בכותרת Mcp-Session-Id, שה-client שלח בכל בקשה עד שסיים אותה ב-HTTP DELETE. ובנוסף ל-handshake היה ping, בקשה שכל צד יכול לשלוח כדי לבדוק שהצד השני חי. ב-2026-07-28 ה-ping, ה-initialize וה-session הוסרו כולם.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"logging": {},
"prompts": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"tools": { "listChanged": true }
},
"serverInfo": {
"name": "ExampleServer",
"version": "1.0.0"
},
"instructions": "Optional instructions for the client"
}
}תאימות בין הדורות: modern, legacy ו-dual-era
מה קורה כשהדורות נפגשים? client מודרני מול שרת legacy נכשל. השרת עלול לדחות בשגיאה כלשהי, לא לענות בכלל, או אפילו לעבד method שקיים בשני הדורות (כמו tools/call) לפי הכללים הישנים. client legacy מול שרת מודרני נכשל גם הוא: ב-stdio השרת דוחה את initialize (method לא מוכר, ובלי שדות ה-_meta), וב-HTTP הבקשה נדחית ב-400 כי חסרות בה כותרות חובה. ל-client ישן אין דרך לעבור לגרסה חדשה, ולכן שרת מודרני צריך (SHOULD) לציין בשגיאה ל-initialize באילו גרסאות הוא כן תומך.
כדי לעבוד עם שני הדורות, client בונה את עצמו כ-dual-era ומזהה את הדור של השרת. ב-stdio הוא שולח server/discover לפני כל בקשה אחרת. אם חזר DiscoverResult, השרת מודרני. אם חזרה שגיאה מודרנית מוכרת, כמו -32022, השרת מודרני אבל בגרסה אחרת, ולכן בוחרים גרסה מתוך supported ולא חוזרים ל-initialize. כל שגיאה אחרת, או חוסר תשובה בתוך timeout, אומרים שהשרת legacy, ואז עוברים ל-initialize.
חשוב: ההחלטה לחזור ל-initialize לא יכולה (MUST NOT) להיות תלויה בקוד שגיאה מסוים. שרתים ישנים עונים לבקשה לא מוכרת לפני initialize בקודים שונים (לרוב -32601 או -32602), או לא עונים בכלל. גם client שתומך רק בדור המודרני כדאי שישלח את הבדיקה: היא הופכת כשל עמום לכשל ברור.
ב-HTTP, ה-client שולח בקשה מודרנית ובודק את הגוף של תשובת 400. גוף עם שגיאה מודרנית מוכרת פירושו שרת מודרני, ואז מתקנים את הבקשה או בוחרים גרסה. גוף ריק או לא מוכר פירושו legacy, ואז עוברים ל-initialize. הדור הוא תכונה של השרת, לא של בקשה, ולכן ה-client צריך לשמור את התוצאה לכל חיי התהליך (stdio) או ה-origin (HTTP).
שרת dual-era בוחר התנהגות לפי הדרך שבה ה-client פותח: בקשה עם _meta מודרני מקבלת טיפול stateless, ובקשת initialize פותחת session legacy (לכל תהליך ב-stdio, או לכל session ב-HTTP). הוא רשאי לשרת את שני הדורות במקביל, באותו endpoint או תהליך.

סקיצה ב-TypeScript: _meta בצד ה-client ובדיקה בצד השרת
הקוד מממש את שלושת הכללים של הפרק. buildMeta בונה את ה-_meta שה-client מצרף לכל בקשה. checkRequestMeta רצה בשרת לפני כל handler (בהמשך ל-dispatcher מפרק 2), ומחזירה את השגיאה המדויקת: -32602 כששדה חובה חסר, -32022 לגרסה לא נתמכת ו--32021 כשחסרה יכולת נדרשת. pickVersion היא הצד של ה-client בסבב החוזר אחרי -32022.
שימו לב ש-checkRequestMeta מקבלת את היכולות שה-handler הספציפי דורש. כלי שאף פעם לא שואל את המשתמש לא צריך לדרוש elicitation, ולכן לא כדאי לבדוק יכולות גלובלית לכל השרת.
pickVersion מקבלת את הגרסאות של ה-client לפי סדר עדיפות, מהחדשה לישנה, ומחזירה את הראשונה שמופיעה גם ב-supported של השרת. אם היא מחזירה null, אין גרסה משותפת, וה-client צריך להציג שגיאה ולא לנסות שוב בלולאה. client אמיתי גם ישמור את הגרסה שנבחרה לכל שרת, כדי שהבקשות הבאות יישלחו ישר בגרסה הנכונה.
const SUPPORTED_VERSIONS: string[] = ["2026-07-28"];
const P = "io.modelcontextprotocol/";
interface ClientCapabilities {
elicitation?: { form?: object; url?: object };
extensions?: Record<string, object>;
}
type Meta = Record<string, unknown>;
type RpcError = { code: number; message: string; data?: unknown };
// client: כל בקשה נושאת את הגרסה והיכולות שלה
function buildMeta(version: string, caps: ClientCapabilities, extra: Meta = {}): Meta {
return {
[`${P}protocolVersion`]: version,
[`${P}clientCapabilities`]: caps,
[`${P}clientInfo`]: { name: "my-host", version: "1.4.0" },
...extra,
};
}
// שרת: רץ לפני כל handler, עם היכולות שה-handler הספציפי דורש
function checkRequestMeta(meta: Meta | undefined, requires: ClientCapabilities = {}): RpcError | null {
const version = meta?.[`${P}protocolVersion`];
const caps = meta?.[`${P}clientCapabilities`];
if (typeof version !== "string" || typeof caps !== "object" || caps === null) {
return { code: -32602, message: "Missing required _meta fields" };
}
if (!SUPPORTED_VERSIONS.includes(version)) {
return {
code: -32022,
message: "Unsupported protocol version",
data: { supported: SUPPORTED_VERSIONS, requested: version },
};
}
const missing = Object.entries(requires).filter(([name]) => !(name in caps));
if (missing.length > 0) {
return {
code: -32021,
message: `Missing required client capability: ${missing.map(([name]) => name).join(", ")}`,
data: { requiredCapabilities: Object.fromEntries(missing) },
};
}
return null;
}
// client: אחרי -32022, בוחרים את הגרסה המועדפת שגם השרת תומך בה
function pickVersion(preferred: string[], err: RpcError): string | null {
const supported = (err.data as { supported?: string[] } | undefined)?.supported ?? [];
return preferred.find((v) => supported.includes(v)) ?? null;
}