Transports — איך ההודעות עוברות בפועל
Transport הוא binding, לא פרוטוקול
כבר ראינו בנושא AI Agents, בפרק Tools ו-MCP, ששרת יכול לרוץ כתהליך מקומי דרך stdio או כשירות מרוחק ב-HTTP. כאן נראה בדיוק איך ההודעות עוברות בכל אחד מהם.
ה-spec מגדיר transport כ-binding. הוא קובע איך הודעות נארזות ונמסרות, איך עוברת המטא-דאטה של הבקשה, ואיך מסמנים ביטול וסגירה. הוא לא משנה את משמעות ההודעות: ה-methods, ה-_meta ודפוסי ההודעות זהים בכל transport. הודעות JSON-RPC חייבות להיות מקודדות ב-UTF-8.
יש רק שני כיווני תנועה: ה-client שולח requests ו-notifications, והשרת שולח responses ו-notifications. ב-2026-07-28 שרת לא שולח requests ו-client לא שולח responses. זה מה שמאפשר לכל transport להיות פשוט.
ה-spec מגדיר שני transports סטנדרטיים, stdio ו-Streamable HTTP, ומתיר transports מותאמים אישית. transport מותאם חייב לשמור על פורמט JSON-RPC, על דפוסי ההודעות ועל מודל ה-_meta. אם הוא רץ מעל זרם בתים דו-כיווני אמין (Unix socket, TCP), הוא צריך (SHOULD) להשתמש באותה חלוקה לשורות של stdio.
stdio: שורה אחת, הודעה אחת
ב-stdio ה-client מפעיל את השרת כתהליך-משנה. השרת קורא הודעות מ-stdin וכותב הודעות ל-stdout. כל הודעה היא request, notification או response אחד, וההודעות מופרדות בירידת שורה. להודעה אסור (MUST NOT) להכיל ירידות שורה בתוכה, ולכן כל הודעה נכתבת כשורת JSON אחת.
stdout שמור לפרוטוקול בלבד: השרת לא יכול (MUST NOT) לכתוב אליו שום דבר שאינו הודעת MCP תקינה. זו הטעות הנפוצה ביותר בשרתי stdio. console.log אחד שנשכח יוצר שורה שאינה JSON, וה-client נכשל בפענוח. ללוגים יש stderr: השרת רשאי לכתוב אליו טקסט UTF-8 חופשי, וה-client רשאי לאסוף אותו, להעביר אותו הלאה או להתעלם ממנו. ה-client גם לא אמור להניח שכל פלט ל-stderr הוא שגיאה.
כל ההודעות עוברות בערוץ אחד, בלי streams נפרדים לכל בקשה. השרת כותב ל-stdout שלושה סוגי הודעות: תשובות לבקשות (מותאמות לפי id), notifications ששייכות לבקשה פעילה (כמו progress), ו-notifications של subscriptions, שה-client מתאים לפי io.modelcontextprotocol/subscriptionId.
ב-stdio אין שכבת כותרות: כל המטא-דאטה יושבת ב-_meta שבגוף ההודעה. גם אימות לא עובר דרך הפרוטוקול. ה-spec קובע ששרתי stdio לא אמורים להשתמש במנגנון ה-OAuth של MCP, אלא לקבל credentials מהסביבה, בדרך כלל ממשתני סביבה שה-host מגדיר.

stdio: הפעלה, קונפיגורציה וסגירה
איך ה-host יודע איזה תהליך להפעיל? זה לא חלק מה-spec, וכל host מגדיר את זה בעצמו. הצורה הנפוצה היא קובץ JSON עם מפה mcpServers: לכל שרת יש שם, command להרצה, args ו-env. הדוגמה מייצגת את הצורה הזו, אבל שם הקובץ והשדות המדויקים משתנים בין hosts, ולכן כדאי לבדוק בתיעוד של ה-host שלכם.
סגירה מסודרת לפי ה-spec: ה-client סוגר את ה-stdin של השרת, מחכה שהתהליך יסתיים, ואם זה לא קורה תוך זמן סביר מסיים אותו בכוח. ב-POSIX זה בדרך כלל SIGTERM ואחריו SIGKILL, וב-Windows זה TerminateProcess או Job Objects. בצד השרת: כשה-stdin נסגר (EOF), השרת צריך (SHOULD) לצאת מיד. זה אות הסגירה הראשי והיחיד שעובד בכל מערכת הפעלה.
אם השרת קורס, ה-client צריך (SHOULD) להפעיל אותו מחדש. מכיוון שהפרוטוקול stateless, בקשות שהיו באוויר פשוט אבדו, וה-client יכול לשלוח אותן שוב לתהליך החדש. subscriptions פעילים צריך לפתוח מחדש, כי לשרת אין מצב שעובר בין חיבורים.
{
"mcpServers": {
"issues": {
"command": "node",
"args": ["/opt/mcp/issues-server/build/index.js"],
"env": {
"ISSUES_API_TOKEN": "<token from your secret store>"
}
}
}
}Streamable HTTP: endpoint אחד, POST לכל הודעה
ב-Streamable HTTP השרת הוא תהליך עצמאי שמשרת clients רבים. הוא חושף endpoint יחיד, ה-MCP endpoint (למשל https://example.com/mcp), שמקבל POST. כל הודעת JSON-RPC של ה-client היא בקשת POST חדשה.
כללי השליחה: ה-client חייב לשלוח כותרת Accept שכוללת גם application/json וגם text/event-stream, ולצרף את כותרות המטא-דאטה (בסעיף הבא). גוף ה-POST הוא request או notification יחיד, אף פעם לא response.
כללי התשובה: לבקשה, השרת בוחר בין Content-Type של application/json, אובייקט JSON יחיד עם התשובה, לבין text/event-stream, stream של SSE (בהמשך). ה-client חייב לתמוך בשניהם. ל-notification שהתקבלה השרת עונה 202 Accepted בלי גוף, ול-notification שנדחתה בסטטוס שגיאה כמו 400.
בפועל, ב-2026-07-28 אין notification שה-client שולח ב-HTTP. הדוגמה היחידה בליבה, notifications/cancelled, משמשת רק ב-stdio, כי ב-HTTP סגירת ה-stream היא עצמה הביטול. הכללים ל-notification קיימים בשביל הרחבות.

כותרות חובה: שיקוף של הגוף
Streamable HTTP משקף שדות מהגוף לכותרות HTTP, כדי שרכיבי ביניים (load balancers, gateways, כלי ניטור) יוכלו לנתב ולבדוק בקשות בלי לפענח את הגוף. MCP-Protocol-Version חובה בכל POST, והערך שלו חייב להיות זהה ל-io.modelcontextprotocol/protocolVersion שב-_meta. Mcp-Method חובה בכל בקשה ומכילה את ה-method. Mcp-Name חובה ב-tools/call, resources/read ו-prompts/get, ומכילה את params.name או params.uri.
הגוף הוא מקור האמת, והשרת חייב לדחות בקשה שבה הכותרות לא תואמות לגוף: סטטוס 400 עם HeaderMismatch (-32020). זו לא סתם קפדנות. אם ה-load balancer מנתב לפי הכותרת והשרת מבצע לפי הגוף, אי-התאמה היא פרצת אבטחה. גם כותרת חובה חסרה היא HeaderMismatch. שמות כותרות לא תלויים באותיות גדולות או קטנות, אבל הערכים כן.
ערך שלא ניתן לייצג כ-ASCII פשוט, כמו שם עם תווים שאינם ASCII, ירידת שורה או רווחים בקצוות, נשלח בקידוד Base64 בפורמט הקבוע =?base64?...?=. השרת חייב לפענח אותו לפני ההשוואה לגוף.
שרת יכול גם לסמן פרמטר של כלי ב-x-mcp-header בתוך ה-inputSchema. במקרה כזה ה-client חייב לשקף את הערך שלו לכותרת Mcp-Param-{name}, למשל Mcp-Param-Region: us-west1, כדי שרכיבי ביניים יוכלו לנתב לפיו. זה מותר רק לפרמטרים פרימיטיביים, ואסור לסמן כך סודות או מידע אישי, כי כותרות גלויות לכל רכיב ביניים. נחזור לזה בפרק 5.
שני סטטוסים נוספים: שרת שלא מממש את הגרסה מחזיר 400 עם -32022, ושרת שלא מממש את ה-method מחזיר 404 עם -32601. הגוף עם קוד ה-JSON-RPC הוא מה שמבדיל בין 404 כזה לבין 404 של שרת ישן שאין לו MCP endpoint בכלל.
POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search_issues
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_issues","arguments":{"query":"login bug"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"progressToken":"call-2"}}}
SSE: תשובה שזורמת
כשהשרת עונה ב-text/event-stream, ה-stream שייך לבקשה אחת בלבד. לפני התשובה הסופית השרת רשאי לשלוח notifications שקשורות לבקשה הזו, כמו notifications/progress או הודעות לוג, והתשובה הסופית אמורה לסגור את ה-stream. ה-stream בדוגמה מכיל שתי הודעות התקדמות ואז את ה-result.
השרת לא יכול (MUST NOT) לשלוח על ה-stream בקשות עצמאיות. אם הוא צריך מידע מה-client, הוא מחזיר InputRequiredResult (פרק 7). זה שינוי מ-2025-03-26 עד 2025-11-25, שבהן שרת יכול היה לשלוח בקשות על streams של SSE.
שני פרטים תפעוליים מה-spec: שרת צריך (SHOULD) לשלוח X-Accel-Buffering: no בתשובה, כדי ש-reverse proxy כמו nginx לא יאגור אירועים. ב-streams ארוכים מומלץ לשלוח מדי פעם שורת הערה של SSE, שורה שמתחילה בנקודתיים, כ-keep-alive, כדי שרכיבי ביניים לא יסגרו חיבור שקט.
ב-2026-07-28 אין חידוש streams: Last-Event-ID לא נתמך. אם ה-stream נקטע, הבקשה שהייתה באוויר אבדה, וה-client חייב לשלוח אותה שוב כבקשה חדשה עם id חדש. זו עוד סיבה לתכנן כלים כך ששליחה חוזרת לא תזיק (idempotent), או לחשוף handle שבעזרתו אפשר לבדוק מה קרה.
HTTP/1.1 200 OK
Content-Type: text/event-stream
X-Accel-Buffering: no
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"call-2","progress":1,"total":3,"message":"Searching open issues"}}
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"call-2","progress":2,"total":3,"message":"Searching closed issues"}}
data: {"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"Found 12 matching issues"}]}}
אבטחה: Origin, localhost ואימות
ה-spec מגדיר שלוש דרישות ל-Streamable HTTP. הראשונה: השרת חייב לבדוק את כותרת Origin בכל חיבור. אם היא קיימת ולא תקינה, הוא מחזיר 403 Forbidden.
הסיבה היא DNS rebinding. אתר זדוני שנפתח בדפדפן של המשתמש יכול לגרום לדומיין שלו להצביע פתאום על 127.0.0.1, ואז קוד JavaScript באתר שולח בקשות לשרת MCP שרץ מקומית על המחשב, עם כל ההרשאות שלו. בדיקת Origin חוסמת את זה, כי הדפדפן שולח את ה-Origin של האתר האמיתי.
השנייה: שרת שרץ מקומית צריך (SHOULD) להאזין רק ל-localhost (127.0.0.1) ולא לכל הממשקים (0.0.0.0). אחרת כל מחשב ברשת המקומית יכול לדבר איתו. השלישית: השרת צריך (SHOULD) לממש אימות לכל החיבורים. את מנגנון ה-OAuth של MCP נפרט בפרק 10.

Subscriptions: subscriptions/listen
תשובה לבקשה נסגרת כשהבקשה מסתיימת. אבל לפעמים ה-client רוצה לדעת על שינויים לאורך זמן: הרשימה של הכלים השתנתה, או ש-resource מסוים עודכן. לשם כך יש subscriptions/listen, בקשה ארוכת טווח שהתשובה שלה היא stream פתוח של notifications.
ב-params של הבקשה יש מסנן notifications עם ארבעה שדות אופציונליים. toolsListChanged, promptsListChanged ו-resourcesListChanged הם booleans שמבקשים הודעה כשהרשימה המתאימה משתנה. resourceSubscriptions הוא מערך של URIs שעליהם ה-client רוצה לקבל notifications/resources/updated. השרת לא יכול (MUST NOT) לשלוח סוג notification שה-client לא ביקש במפורש.
ב-HTTP, התשובה ל-POST הזה היא stream של SSE שנשאר פתוח. ב-stdio, ה-notifications עוברות ב-stdout המשותף, וה-client מפריד ביניהן לפי subscriptionId. בשני המקרים, client רשאי לפתוח כמה subscriptions במקביל, למשל אחד לשינויים ברשימת הכלים ואחד לעדכוני resources.
{
"jsonrpc": "2.0",
"id": 30,
"method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
Subscriptions: אישור, notifications וסגירה
ההודעה הראשונה על ה-stream חייבת להיות notifications/subscriptions/acknowledged, ושום notification של ה-subscription לא יכולה להגיע לפניה. השדה notifications באישור מכיל רק את מה שהשרת הסכים לכבד. סוגים שהוא לא תומך בהם פשוט חסרים, וה-client צריך (SHOULD) להשוות את האישור לבקשה ולהתמודד עם ההבדל.
כל notification על ה-stream נושאת ב-_meta את io.modelcontextprotocol/subscriptionId, וערכו הוא ה-id של בקשת ה-subscriptions/listen. בדוגמה הבקשה נשלחה עם id 30, ולכן האישור וכל ההודעות אחריו נושאים 30. למשל, עדכון resource נראה כך: method של notifications/resources/updated, ב-params ה-uri, וב-_meta אותו subscriptionId.
notifications שקשורות לבקשה מסוימת, כמו progress ולוג, לא עוברות על ה-stream של subscriptions/listen. הן זורמות רק על ה-stream של הבקשה שהן שייכות אליה.
subscription מסתיים כשה-client מבטל אותו (סוגר את ה-SSE ב-HTTP, או שולח notifications/cancelled ב-stdio), כשהשרת מסיים אותו, או כשה-transport נסגר. כשהשרת מסיים ביוזמתו, למשל בכיבוי, הוא צריך (SHOULD) לשלוח תשובה מוצלחת לבקשת ה-listen המקורית עם resultType של complete, ורק אז לסגור. כך ה-client מבחין בין סגירה מסודרת לבין ניתוק, שאחריו כדאי להתחבר מחדש.
{
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": 30
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}ביטול בקשות ו-timeouts
את notifications/cancelled ראינו בפרק 2. כאן חשוב איך הביטול עובר בכל transport. ב-Streamable HTTP, סגירת ה-SSE של הבקשה היא הביטול, והשרת חייב להתייחס לניתוק כאל ביטול. לא נשלחת הודעה, וזה חד-משמעי כי לכל בקשה יש stream משלה. ב-stdio אין stream לסגור, ולכן ה-client חייב לשלוח notifications/cancelled עם ה-id של הבקשה. בשני המקרים, השרת צריך להפסיק לעבוד ולא לשלוח שום הודעה נוספת על הבקשה.
שרת רשאי לשלוח notifications/cancelled רק כדי לסגור subscription (עם ה-id של בקשת ה-listen), ולא לשום מטרה אחרת.
מרוצים הם דבר רגיל: הביטול יכול להגיע אחרי שהשרת כבר סיים ושלח תשובה. לכן השרת מתעלם מביטול של בקשה לא מוכרת או שהסתיימה, וה-client מתעלם מתשובה שמגיעה אחרי שביטל.
timeouts: שני הצדדים צריכים (SHOULD) להגדיר timeout לכל בקשה, ורצוי שיהיה ניתן להגדרה לכל בקשה בנפרד. כשה-timeout פג, מבטלים את הבקשה כמו שתואר למעלה. מותר לאפס את השעון כשמגיעה הודעת progress, כי היא מראה שיש עבודה, אבל צריך תמיד להחזיק גם timeout מקסימלי, כדי ששרת שמדווח התקדמות בלי סוף לא יתקע את ה-client.
Progress — דיווח התקדמות
client שרוצה לקבל עדכוני התקדמות על בקשה מוסיף progressToken ל-_meta שלה. זו מחרוזת או מספר שלם שה-client בוחר, והיא חייבת להיות ייחודית בין כל הבקשות הפעילות. השרת רשאי לשלוח notifications/progress עם אותו token, או לא לשלוח בכלל.
notification של progress מכילה progressToken, progress (הערך הנוכחי), total אופציונלי ו-message אופציונלי לתצוגה. progress חייב לעלות בכל הודעה, גם כשה-total לא ידוע, ושני הערכים יכולים להיות שברים. אחרי שהבקשה מסתיימת, ההודעות חייבות להפסיק.
ב-HTTP ההודעות זורמות על ה-SSE של הבקשה עצמה, כמו בדוגמה בסעיף SSE. ב-stdio הן עוברות ב-stdout המשותף, וה-client מתאים אותן לבקשה לפי ה-token. כדאי להגביל את הקצב בשני הצדדים, כדי ששרת לא יציף את ה-client באלפי עדכונים.
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "call-2",
"progress": 2,
"total": 3,
"message": "Searching closed issues"
}
}בגרסאות קודמות: sessions, GET ו-HTTP+SSE
בגרסאות 2025-03-26 עד 2025-11-25, Streamable HTTP נראה אחרת. השרת יכול היה להקצות session בכותרת Mcp-Session-Id, שהסתיים ב-HTTP DELETE. ה-client יכול היה לפתוח stream עצמאי של SSE ב-HTTP GET כדי לקבל הודעות ביוזמת השרת. השרת יכול היה לשלוח בקשות JSON-RPC על streams של SSE. ו-streams היו ניתנים לחידוש עם Last-Event-ID. אף אחד מאלה לא קיים ב-2026-07-28. מה שהחליף אותם: _meta לכל בקשה, subscriptions/listen, MRTR, ושליחה חוזרת של בקשה שנקטעה.
שרת שתומך רק ב-2026-07-28 ומקבל תנועה כזו צריך (SHOULD) לענות 405 Method Not Allowed ל-GET ו-DELETE, להתעלם מ-Mcp-Session-Id (ולא להנפיק או להחזיר session IDs), ולהתעלם מ-Last-Event-ID.
ועוד דור אחד אחורה: HTTP+SSE של 2024-11-05, עם endpoint נפרד ל-SSE ול-POST. הוא deprecated מאז 2025-03-26, ומימושים חדשים לא אמורים לאמץ אותו. client שרוצה לתמוך גם בו שולח POST לכתובת. אם קיבל 400, 404 או 405 וגוף התשובה אינו שגיאה מודרנית מוכרת, הוא שולח GET לאותה כתובת ומצפה ל-stream של SSE שהאירוע הראשון בו הוא endpoint.
סקיצה: חלוקה לשורות ב-stdio
הקוד מממש את צד הקריאה של stdio. הנתונים מגיעים ב-chunks שלא מיושרים להודעות: chunk יכול להסתיים באמצע שורה, ואפילו באמצע תו UTF-8 רב-בייטי (עברית, למשל). לכן LineFramer מחזיק buffer, מפענח עם TextDecoder במצב stream, ומוציא רק שורות שלמות.
שורה שאינה JSON תקין לא מפילה את הקורא. היא מסומנת כך שהקוד הקורא יוכל להחזיר -32700 (Parse error). בצד הכתיבה, JSON.stringify אף פעם לא מייצר ירידת שורה אמיתית (בתוך מחרוזות הוא מקודד אותה כ-escape), ולכן הוספת ירידת שורה אחת בסוף מספיקה כדי לעמוד בכלל של ה-spec.
בחלק התחתון, החיבור לתהליך ב-Node: כל הודעה עוברת ל-dispatcher מפרק 2, התשובה נכתבת ל-stdout, ו-EOF ב-stdin מסיים את התהליך, כפי שה-spec מבקש.
type Framed = { ok: true; message: unknown } | { ok: false; line: string };
class LineFramer {
private decoder = new TextDecoder("utf-8");
private buffer = "";
push(chunk: Uint8Array): Framed[] {
// stream: true שומר בייטים של תו שנחתך עד ה-chunk הבא
this.buffer += this.decoder.decode(chunk, { stream: true });
const out: Framed[] = [];
let newline: number;
while ((newline = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
this.buffer = this.buffer.slice(newline + 1);
if (line.trim() === "") continue;
try {
out.push({ ok: true, message: JSON.parse(line) });
} catch {
out.push({ ok: false, line });
}
}
return out;
}
}
function frame(message: unknown): string {
return JSON.stringify(message) + "\n";
}
// חיבור לתהליך השרת ב-Node
declare function dispatch(raw: unknown): Promise<unknown>; // ה-dispatcher מפרק 2
const framer = new LineFramer();
process.stdin.on("data", async (chunk: Buffer) => {
for (const item of framer.push(chunk)) {
const reply = item.ok
? await dispatch(item.message)
: { jsonrpc: "2.0", error: { code: -32700, message: "Parse error" } };
if (reply !== null) process.stdout.write(frame(reply));
}
});
process.stdin.on("end", () => process.exit(0)); // EOF = אות לסגירה
console.error("issues-server ready"); // לוגים רק ל-stderrסקיצה: POST handler של Streamable HTTP
הסקיצה מראה את הבדיקות ששרת Streamable HTTP מבצע לפני שהבקשה מגיעה ל-dispatcher: רק POST (405 לכל השאר), בדיקת Origin (403), 202 ל-notification, התאמה בין כותרות לגוף (400 עם -32020), מיפוי קודי שגיאה לסטטוס HTTP (-32601 ל-404, -32021 ו--32022 ל-400), והאזנה ל-127.0.0.1 בלבד.
זו סקיצה להמחשה, לא שרת לפרודקשן. היא לא בודקת את כותרת Accept, לא מפענחת ערכי Base64 בכותרות, לא מממשת אימות ותמיד עונה ב-application/json. בפרק 8 נשתמש ב-transport של ה-SDK הרשמי, שמטפל בכל אלה.
שימו לב לסדר: בדיקות שלא דורשות לקרוא את הגוף (method, Origin) רצות ראשונות, כך שבקשה חשודה נדחית לפני שהשרת מבזבז עליה משאבים. את ההתאמה בין כותרות לגוף בודקים לפני ה-dispatcher, כי זו הבדיקה שמגינה על כל רכיבי הביניים שכבר קיבלו החלטות לפי הכותרות.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
declare function dispatch(raw: unknown): Promise<unknown>; // ה-dispatcher מפרק 2
type Body = {
id?: string | number;
method?: string;
params?: { name?: string; uri?: string; _meta?: Record<string, unknown> };
};
const ALLOWED_ORIGINS = new Set(["http://localhost:5173"]);
const NAME_METHODS = new Set(["tools/call", "resources/read", "prompts/get"]);
function send(res: ServerResponse, status: number, body?: unknown) {
res.writeHead(status, body === undefined ? {} : { "Content-Type": "application/json" });
res.end(body === undefined ? undefined : JSON.stringify(body));
}
function statusFor(response: unknown): number {
const code = (response as { error?: { code: number } } | null)?.error?.code;
if (code === -32601) return 404;
if (code === -32021 || code === -32022) return 400;
return 200;
}
async function readBody(req: IncomingMessage): Promise<string> {
let data = "";
for await (const chunk of req) data += chunk;
return data;
}
createServer(async (req, res) => {
if (req.url !== "/mcp") return send(res, 404);
if (req.method !== "POST") return send(res, 405); // אין GET/DELETE ב-2026-07-28
const origin = req.headers.origin;
if (origin !== undefined && !ALLOWED_ORIGINS.has(origin)) return send(res, 403); // DNS rebinding
let msg: Body;
try {
msg = JSON.parse(await readBody(req));
} catch {
return send(res, 400, { jsonrpc: "2.0", error: { code: -32700, message: "Parse error" } });
}
if (msg.id === undefined) return send(res, 202); // notification
// הכותרות חייבות לשקף את הגוף. Node מחזיר שמות כותרות באותיות קטנות
const expected: Record<string, unknown> = {
"mcp-protocol-version": msg.params?._meta?.["io.modelcontextprotocol/protocolVersion"],
"mcp-method": msg.method,
};
if (msg.method !== undefined && NAME_METHODS.has(msg.method)) {
expected["mcp-name"] = msg.params?.name ?? msg.params?.uri;
}
for (const [header, bodyValue] of Object.entries(expected)) {
if (req.headers[header] !== bodyValue) {
return send(res, 400, {
jsonrpc: "2.0",
id: msg.id,
error: { code: -32020, message: `Header mismatch: ${header}` },
});
}
}
const response = await dispatch(msg);
send(res, statusFor(response), response);
}).listen(3000, "127.0.0.1"); // שרת מקומי מאזין רק ל-localhost