הוספת תמיכה ב-MCP לאפליקציה (Host ו-Client)

ה-host הוא ה-harness

עד עכשיו עמדנו בצד השרת. הפרק הזה עונה על השאלה מהצד השני: מה צריך לבנות כדי שאפליקציה תתמוך בשרתי MCP. כבר ראינו בנושא AI Agents, בפרק The Harness, שה-harness הוא הקוד שמריץ את הסוכן בפועל: הוא מחזיק את ההיסטוריה, קורא למודל, מבצע כלים ואוכף גבולות. ובפרק Agents והלולאה האגנטית ראינו את הלולאה עצמה: מודל, קריאה לכלי, תוצאה, ושוב מודל.

host של MCP הוא בדיוק ה-harness הזה, עם מקור נוסף לכלים. במקום שכל כלי ייכתב בקוד האפליקציה, ה-harness מקבל חלק מהכלים משרתי MCP. הלולאה עצמה לא משתנה. מה שנוסף הוא שכבה שמגלה כלים משרתים, מתרגמת אותם לפורמט של ספק המודל, ומנתבת כל קריאה של המודל חזרה לשרת הנכון.

בפרק נבנה את השכבה הזו עם ה-client הרשמי של ה-SDK. הקוד נבדק מול @modelcontextprotocol/client בגרסה 2.1.0 ומול notes-server מפרק 8: שני שרתים מחוברים במקביל, מודל מדומה שמפעיל כלים, אישור משתמש, elicitation ושגיאות. ה-SDK מתפתח, ולכן כדאי לבדוק את ה-API העדכני ב-ts.sdk.modelcontextprotocol.io.

צ'קליסט: מה host צריך לבנות

קונפיגורציה ו-registry: רשימת שרתים, איך מפעילים כל אחד (פקודת stdio או URL), אילו מופעלים, ומי מהם אמין. מכאן נגזרים ה-transports: תהליך-משנה לכל שרת stdio, ו-HTTP client לכל שרת מרוחק (פרק 4). גילוי: server/discover או בדיקת גרסה, כולל תמיכה בשרתים של 2025-11-25 (פרק 3).

רשימות: הבאת tools, resources ו-prompts, כולל pagination, שמירה ב-cache לפי ttlMs, ו-subscriptions/listen כדי לדעת מתי לרענן (פרקים 4 ו-5). תרגום: הפיכת כל Tool של MCP לכלי בפורמט של ספק המודל, עם שמות ייחודיים בין שרתים. ניתוב: כל tool_use של המודל עובר ל-tools/call של השרת הנכון.

תוצאות: הפיכת content, structuredContent ו-isError להודעת tool_result שהמודל מבין. אינטראקציה: UI ל-elicitation, שמראה איזה שרת שואל (פרק 7), ואישור משתמש לפני הפעלת כלים. תפעול: timeouts, ביטול כשהמשתמש עוצר, הצגת התקדמות, הפעלה מחדש של שרת שקרס וטיפול בשגיאות.

זו רשימה ארוכה, אבל ה-SDK מכסה את חלקה הגדול: transports, _meta, בחירת גרסה, MRTR ו-cache. מה שנשאר לכם הוא בעיקר החלקים שתלויים באפליקציה: תרגום לפורמט של המודל, UI, מדיניות אישורים וניהול קונטקסט.

חיבור לשרת עם ה-SDK

לכל שרת יוצרים Client משלו, כי client ושרת הם ביחס 1:1 (פרק 1). ב-constructor מצהירים על ה-capabilities של ה-client, כאן elicitation במצב form. שימו לב במיוחד ל-versionNegotiation: בגרסה שבדקנו ברירת המחדל של ה-client היא legacy, כלומר handshake של initialize בסגנון 2025-11-25. רק mode: auto גורם לו לשלוח קודם server/discover ולבחור את 2026-07-28 כשהשרת תומך בה. בבדיקה מול notes-server, auto הניב חיבור modern בגרסה 2026-07-28, וברירת המחדל הניבה legacy בגרסה 2025-11-25.

setRequestHandler עבור elicitation/create רושם את ה-UI של ה-host. בחיבור מודרני ה-SDK משתמש באותו handler כדי למלא אוטומטית תשובות input_required: callTool שולח את הבקשה, מקבל input_required, מפעיל את ה-handler, ושולח את הבקשה שוב עם inputResponses, והכל בתוך קריאה אחת (פרק 7). כאן ה-handler דוחה בקשות במצב url, כי ל-host הזה אין עדיין UI בטוח לפתיחת קישורים. host אמיתי יציג את הכתובת המלאה ויבקש הסכמה.

StdioClientTransport מפעיל את השרת כתהליך-משנה לפי command ו-args. לשרת מרוחק משתמשים ב-StreamableHTTPClientTransport עם ה-URL. listTools מחזיר דף אחד ו-nextCursor, ולכן הלולאה אוספת את כל הדפים. הפונקציה מחזירה ממשק מינימלי, ToolSource, שהשכבות הבאות משתמשות בו בלי לדעת על ה-SDK.

TypeScript
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import type { McpCallResult, McpTool, ToolSource } from "./core.js";

type AskUser = (server: string, message: string, schema: unknown) => Promise<{ action: "accept" | "decline" | "cancel"; content?: Record<string, string | number | boolean> }>;

export async function connectStdio(
  serverName: string,
  command: string,
  args: string[],
  askUser: AskUser,
): Promise<ToolSource & { close(): Promise<void> }> {
  const client = new Client(
    { name: "my-host", version: "1.0.0" },
    {
      capabilities: { elicitation: { form: {} } },
      versionNegotiation: { mode: "auto" }, // ברירת המחדל ב-SDK היא legacy
    },
  );
  // input_required ממולא אוטומטית דרך ה-handler הזה, בתוך callTool
  client.setRequestHandler("elicitation/create", async (request) => {
    if (request.params.mode === "url") return { action: "decline" };
    return askUser(serverName, request.params.message, request.params.requestedSchema);
  });
  await client.connect(new StdioClientTransport({ command, args }));

  return {
    async listTools() {
      const all: McpTool[] = [];
      let cursor: string | undefined;
      do {
        const page = await client.listTools(cursor ? { cursor } : undefined);
        all.push(...(page.tools as McpTool[]));
        cursor = page.nextCursor;
      } while (cursor);
      return all;
    },
    callTool: async (name, args) => (await client.callTool({ name, arguments: args })) as McpCallResult,
    close: () => client.close(),
  };
}

מ-Tool של MCP לכלי של ספק המודל

כבר בנושא AI Agents, בפרק Tools ו-MCP, ראינו שהשדה inputSchema של MCP נקרא input_schema ב-API של חלק מספקי המודלים, וה-harness מתרגם ביניהם. buildToolIndex עושה את התרגום לכל הכלים מכל השרתים, ובונה מפה מהשם שהמודל רואה לשרת ולכלי המקוריים.

הבעיה העיקרית היא התנגשויות שמות. שני שרתים יכולים לחשוף search, וה-spec ממליץ ל-host על אסטרטגיית הבחנה (פרק 5). כאן כל שם מקבל קידומת של המזהה שה-host נתן לשרת בקונפיגורציה, לא של serverInfo.name, שלא מובטח שיהיה ייחודי. בנוסף, ספקים רבים מגבילים שמות כלים לאותיות, ספרות, קו תחתון ומקף, ולאורך מקסימלי. שמות MCP יכולים להכיל נקודות, ולכן מחליפים תווים לא חוקיים ובודקים שוב שאין התנגשות אחרי ההחלפה.

גם ה-description מקבל את שם השרת. זה עוזר למודל להבחין בין כלים דומים משרתים שונים, וזה מה שמדריך ה-client best practices ממליץ: לקבץ כלים לפי שרת המקור.

TypeScript
// טיפוסים מינימליים לצד ה-host. בקוד אמיתי מייבאים אותם מה-SDK
export interface McpTool {
  name: string;
  title?: string;
  description?: string;
  inputSchema: Record<string, unknown>;
  annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean };
}
export interface McpContent {
  type: string;
  text?: string;
  uri?: string;
  name?: string;
  mimeType?: string;
  resource?: { uri: string; text?: string };
}
export interface McpCallResult { content: McpContent[]; structuredContent?: unknown; isError?: boolean }
export interface ToolSource {
  listTools(): Promise<McpTool[]>;
  callTool(name: string, args: Record<string, unknown>): Promise<McpCallResult>;
}

// הפורמט של ספק המודל, בסגנון Messages API. השמות המדויקים משתנים בין ספקים
export interface ProviderTool { name: string; description: string; input_schema: Record<string, unknown> }
export interface ToolResultBlock { type: "tool_result"; tool_use_id: string; content: string; is_error?: boolean }
export interface Route { server: string; tool: McpTool }

// ספקים רבים מגבילים שמות כלים ל-[a-zA-Z0-9_-] ול-64 תווים
const UNSAFE = /[^a-zA-Z0-9_-]/g;

export async function buildToolIndex(servers: Map<string, ToolSource>) {
  const routes = new Map<string, Route>();
  const tools: ProviderTool[] = [];
  for (const [server, source] of servers) {
    for (const tool of await source.listTools()) {
      const exposed = `${server}__${tool.name}`.replace(UNSAFE, "_").slice(0, 64);
      if (routes.has(exposed)) throw new Error(`Tool name collision after namespacing: ${exposed}`);
      routes.set(exposed, { server, tool });
      tools.push({
        name: exposed,
        description: `[${server}] ${tool.description ?? tool.title ?? tool.name}`,
        input_schema: tool.inputSchema,
      });
    }
  }
  return { routes, tools };
}
משמאל שני שרתים, github ו-jira, וכל אחד חושף כלי בשם search. באמצע חלון ה-host עם טבלת ניתוב: github__search מוביל ל-github, ו-jira__search מוביל ל-jira. מימין המודל רואה את שני השמות עם הקידומות.
ה-host מוסיף לכל כלי קידומת של השרת שלו, ומנתב כל קריאה בחזרה לשרת הנכון

מתוצאת MCP להודעת tool_result

toToolResult הופכת CallToolResult לבלוק tool_result. text עובר כמו שהוא. resource_link הופך להפניה, וה-host יכול להחליט אם לקרוא את ה-resource. resource מוטמע מעביר את הטקסט שלו. סוגים אחרים, כמו image או audio, מוחלפים כאן בהודעה קצרה. host שהספק שלו תומך בתמונות בתוך tool_result יעביר אותן כבלוקים של תמונה.

isError הופך ל-is_error, כך שהמודל רואה שהכלי נכשל ויכול לתקן (פרקים 2 ו-5). structuredContent לא נשלח כאן למודל, כי השרת כבר צריך לשכפל אותו כטקסט ב-content. הוא שימושי לקוד של ה-host, למשל להצגת טבלה ב-UI או ל-code mode.

needsApproval מממשת את כלל ה-annotations מפרק 5: רמזים משרת לא אמין לא נחשבים בכלל, ולכן כל כלי שלו דורש אישור. בשרת אמין, רק כלי שמסומן במפורש readOnlyHint: true עובר בלי אישור. ברירות המחדל של ה-annotations מחמירות, וכך גם הפונקציה.

TypeScript
export function toToolResult(toolUseId: string, result: McpCallResult): ToolResultBlock {
  const parts = result.content.map((c) => {
    switch (c.type) {
      case "text":
        return c.text ?? "";
      case "resource_link":
        return `[resource ${c.name ?? ""}: ${c.uri}]`;
      case "resource":
        return c.resource?.text ?? `[embedded resource ${c.resource?.uri}]`;
      default:
        return `[${c.type} content not forwarded (${c.mimeType ?? "unknown type"})]`;
    }
  });
  return {
    type: "tool_result",
    tool_use_id: toolUseId,
    content: parts.join("\n"),
    ...(result.isError ? { is_error: true } : {}),
  };
}

// annotations הן רמז בלבד, ורק משרת אמין (פרק 5)
export function needsApproval(tool: McpTool, trustedServer: boolean): boolean {
  if (!trustedServer) return true;
  return tool.annotations?.readOnlyHint !== true;
}

הלולאה המלאה

runAgent היא הלולאה האגנטית, עם MCP מתחת. היא בונה את אינדקס הכלים, שולחת למודל את ההודעות והכלים, ואם המודל ביקש כלים, מטפלת בכל tool_use: מוצאת את הנתיב, מבקשת אישור אם צריך, קוראת ל-callTool של השרת הנכון, ומוסיפה את התוצאות כהודעה אחת. llm.chat הוא ממשק כללי ולא SDK של ספק מסוים.

כל כשל חוזר למודל כ-tool_result עם is_error, במקום להפיל את הלולאה: כלי לא מוכר, סירוב של המשתמש, ושגיאת פרוטוקול או timeout שה-SDK זורק כ-exception. כך המודל יכול לבחור כלי אחר, לתקן ארגומנטים או להסביר למשתמש מה קרה. maxSteps מגביל את מספר הסבבים, כדי שמודל שנתקע לא ירוץ בלי סוף.

הלולאה נבדקה מול שני עותקים של notes-server בשמות notes ו-archive, עם מודל מדומה. המודל ראה שישה כלים עם קידומות (notes__create_note, archive__search_notes וכן הלאה). הקריאה ל-notes__delete_note עברה אישור של ה-host, ואז, בתוך callTool, ה-elicitation של השרת הגיע ל-handler ואושר, והפתק נמחק. כלי שלא קיים ו-search עם query ריק חזרו למודל כ-is_error, השני עם הודעת האימות המפורטת של השרת.

TypeScript
import { buildToolIndex, needsApproval, toToolResult, type ProviderTool, type ToolResultBlock, type ToolSource } from "./core.js";

type TextBlock = { type: "text"; text: string };
type ToolUseBlock = { type: "tool_use"; id: string; name: string; input: Record<string, unknown> };
type Message = { role: "user" | "assistant"; content: string | Array<TextBlock | ToolUseBlock | ToolResultBlock> };
export interface Llm {
  chat(request: { messages: Message[]; tools: ProviderTool[] }): Promise<{
    content: Array<TextBlock | ToolUseBlock>;
    stop_reason: "end_turn" | "tool_use";
  }>;
}
type Approve = (server: string, tool: string, args: Record<string, unknown>) => Promise<boolean>;

export async function runAgent(
  llm: Llm,
  servers: Map<string, ToolSource>,
  userText: string,
  approve: Approve,
  trusted: Set<string>,
  maxSteps = 10,
): Promise<string> {
  const { routes, tools } = await buildToolIndex(servers);
  const messages: Message[] = [{ role: "user", content: userText }];

  for (let step = 0; step < maxSteps; step++) {
    const response = await llm.chat({ messages, tools });
    messages.push({ role: "assistant", content: response.content });
    if (response.stop_reason !== "tool_use") {
      return response.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("\n");
    }

    const results: ToolResultBlock[] = [];
    for (const block of response.content) {
      if (block.type !== "tool_use") continue;
      const route = routes.get(block.name);
      if (!route) {
        results.push({ type: "tool_result", tool_use_id: block.id, content: `Unknown tool ${block.name}`, is_error: true });
        continue;
      }
      const needs = needsApproval(route.tool, trusted.has(route.server));
      if (needs && !(await approve(route.server, route.tool.name, block.input))) {
        results.push({ type: "tool_result", tool_use_id: block.id, content: "The user denied this tool call.", is_error: true });
        continue;
      }
      try {
        const result = await servers.get(route.server)!.callTool(route.tool.name, block.input);
        results.push(toToolResult(block.id, result));
      } catch (error) {
        // שגיאת פרוטוקול (JSON-RPC error) או timeout: גם היא חוזרת למודל, מסומנת כשגיאה
        results.push({ type: "tool_result", tool_use_id: block.id, content: `Tool call failed: ${(error as Error).message}`, is_error: true });
      }
    }
    messages.push({ role: "user", content: results });
  }
  throw new Error(`Agent stopped after ${maxSteps} steps`);
}
לולאה סביב עיגול המודל. tool_use יוצא לדמות משתמש עם וי (אישור), משם לנתב שמפצל לשני שרתים, ומהשרת חוזר קו כחול מסומן tool_result אל המודל. במרכז תגית ≤ N למספר הסבבים, ומהמודל חץ שמאלה לתיבת תשובה סופית עם וי.
הלולאה של ה-host: המודל מבקש כלי, המשתמש מאשר, ה-host מנתב לשרת ומחזיר את התוצאה, עד תשובה או עד מגבלת סבבים

הסכמה: המשתמש בלולאה

ה-spec חוזר על אותו עיקרון בכל primitive: תמיד צריך (SHOULD) להיות אדם בלולאה. ה-host צריך להראות אילו כלים חשופים למודל, לסמן בבירור מתי כלי מופעל, ולבקש אישור לפעולות. הוא גם צריך להציג את הארגומנטים לפני הקריאה. ארגומנטים הם ערוץ שבו מידע יוצא מהשיחה, ומודל שהושפע מהזרקה עלול לנסות להדליף דרכם.

מדיניות אישור טובה היא מדורגת. כלי קריאה בלבד משרת אמין עובר בלי שאלה. כלים אחרים מבקשים אישור, עם אפשרות לאשר תמיד לכלי מסוים בשרת מסוים (ולא לכל השרת). פעולות הרסניות מבקשות אישור בכל פעם. חשוב שההחלטה תהיה תלויה בזהות השרת כפי שה-host מכיר אותה, לא ב-serverInfo שהשרת מצהיר עליו.

אישור שניתן לכלי שייך להגדרה שהמשתמש ראה. אם notifications/tools/list_changed מגיע וה-description או ה-inputSchema של כלי מאושר השתנו, ה-host צריך לבטל את האישור ולשאול שוב. אחרת שרת יכול לקבל אישור לכלי תמים ואז להחליף אותו. נרחיב על ה-rug pull הזה בפרק 10.

הרבה שרתים, הרבה כלים: תקציב קונטקסט

בנושא AI Agents, בפרק ניהול קונטקסט בלולאה אגנטית, ראינו שכל token בקונטקסט עולה כסף, זמן ואיכות. עם MCP הבעיה מחריפה: host שמחובר לעשרות שרתים יכול לחשוף מאות כלים, וההגדרות שלהם לבדן יכולות לתפוס חלק גדול מחלון הקונטקסט עוד לפני שהמודל קרא את השאלה.

מדריך ה-client best practices הרשמי מציע progressive discovery. ה-host מביא את הרשימות כרגיל אבל לא מכניס את כולן לקונטקסט. במקומן הוא חושף למודל כלי-על קטן בשם search_tools, שמחזיר שמות ותיאורים קצרים, ורק אחרי שהמודל בוחר, הוא טוען את הסכמה המלאה של הכלי. המדריך ממליץ לעבור למצב הזה לפי סף, למשל כשההגדרות תופסות 1% עד 5% מחלון הקונטקסט. החיפוש יכול להיות מילות מפתח, embeddings, מודל קטן או שילוב, וחלק מספקי המודלים מציעים חיפוש כלים מובנה. אותו רעיון עובד גם ברמת שרתים: להתחבר לשרת רק כשהמשימה צריכה אותו.

צריך לשים לב ל-prompt caching. ספקים שומרים ב-cache את תחילת הפרומפט, כולל מערך הכלים, ושינוי שלו באמצע שיחה מבטל את ה-cache. לכן המדריך ממליץ להוסיף כלים שהתגלו אחרי נקודת ה-cache במקום למיין מחדש, או לנתב הכל דרך כלי-על יציב אחד בסגנון call_tool, ולנתק שרתים רק בגבולות של שיחה.

שני חלונות קונטקסט אנכיים. משמאל, לצד סמל כלי עם ×100, החלון מלא ברצועות כתומות של הגדרות כלים, ורק רצועה כחולה צרה למעלה נשארת למשימה. מימין, בתחתית רצועה עם זכוכית מגדלת מסומנת search_tools, מעליה שתי הגדרות כתומות, ומעליהן שטח כחול גדול שנשאר פנוי למשימה.
progressive discovery: במקום לטעון את כל ההגדרות, כלי חיפוש קטן טוען רק את מה שהמשימה צריכה

Code mode — קריאה פרוגרמטית לכלים

דפוס שני מהמדריך פותר בעיה אחרת: תוצאות ביניים. כשמשימה משרשרת כמה כלים, כל תוצאה עוברת דרך המודל גם אם הוא לא צריך אותה. ב-code mode ה-host מייצר מהסכמות API עם טיפוסים (ה-outputSchema מאפשר טיפוסי החזרה מדויקים), המודל כותב סקריפט שקורא לפונקציות האלה, והסקריפט רץ ב-sandbox. רק הפלט הסופי חוזר למודל.

ה-sandbox לא ניגש לרשת בעצמו. כל קריאה לפונקציה עוברת דרך ה-host, שממיר אותה ל-tools/call ומוסיף את ההרשאות. ה-credentials נשארים אצל ה-host ולא נחשפים לקוד שהמודל כתב.

המדריך מדגיש שמדיניות האישורים חלה גם כאן: אישור של סקריפט לא מאשר אוטומטית כל קריאה שהוא יבצע, ותוצאה משרת אחד היא קלט לא אמין עבור שרת אחר. isError צריך להפוך ל-exception בתוך ה-sandbox, כדי שקוד שהמודל כתב יוכל לתפוס אותו.

שני מסלולים. משמאל שלושה סבבים: המודל קורא לשרת, וגוש תוצאה כתום גדול חוזר אליו לפני הקריאה הבאה. מימין המודל כותב סקריפט (מסמך) שנכנס לארגז כחול מסומן sandbox, ובו ה-host קורא לשני שרתים. רק תוצאה קצרה של שורה אחת חוזרת למודל.
code mode: במקום שכל תוצאת ביניים תעבור דרך המודל, סקריפט רץ ב-sandbox ורק הסיכום חוזר

Timeouts, ביטול וחיבור מחדש

timeout לכל בקשה, שאפשר להגדיר לכל בקשה בנפרד, עם גבול עליון גם כשמגיעות הודעות progress (פרק 4). כשהמשתמש עוצר את הסוכן, ה-host מבטל את הבקשות שבאוויר: סוגר את ה-stream ב-HTTP, או שולח notifications/cancelled ב-stdio. ה-SDK מטפל בזה כשמעבירים לו AbortSignal דרך אפשרויות הבקשה.

progress: host שמציג התקדמות מעביר progressToken ומציג את ה-message של ההודעות, כך שהמשתמש רואה שכלי ארוך עדיין עובד. שרת stdio שקרס מופעל מחדש, בקשות שאבדו נשלחות שוב (בזהירות, רק לכלים idempotent או אחרי שאלת המשתמש), ו-subscriptions נפתחים מחדש. ב-HTTP, stream שנקטע פירושו שליחה מחדש עם id חדש.

cache ורענון: הרשימות נשמרות לפי ttlMs, ומתבטלות מיד כשמגיע list_changed. גם הדור של כל שרת נשמר, modern או legacy, לכל חיי התהליך או ה-origin, כדי לא לבדוק אותו בכל חיבור (פרק 3).

קונפיגורציה ו-registry

הקונפיגורציה של ה-host היא המקום שבו המשתמש או הארגון מחליטים לאילו שרתים לסמוך. הדוגמה מרחיבה את מבנה ה-mcpServers מפרק 4. לשרת stdio יש command ו-args, לשרת מרוחק יש url, ולכל אחד יש דגלים של ה-host עצמו, כמו trusted ו-enabled. השדות האלה הם החלטה של ה-host, לא חלק מה-spec.

סודות לא נכתבים בקובץ עצמו. לשרת stdio הם מגיעים ממאגר סודות למשתני הסביבה של התהליך. לשרת מרוחק ה-host משיג token דרך OAuth (פרק 10) ושומר אותו לפי ה-issuer.

ה-registry הוא גם הבסיס ל-dynamic server management מהסעיף על תקציב הקונטקסט: שרתים שמסומנים enabled: false לא מתחברים בהפעלה, וה-host יכול לחבר אותם לפי הצורך, עם אישור המשתמש.

JSON
{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": [
        "/opt/mcp/notes-server/build/stdio.js"
      ],
      "trusted": true
    },
    "github": {
      "url": "https://mcp.github.example.com/mcp",
      "trusted": false
    },
    "jira": {
      "url": "https://mcp.jira.example.com/mcp",
      "trusted": false,
      "enabled": false
    }
  }
}