בניית MCP Server ב-TypeScript
מה נבנה
בפרק הזה נבנה שרת MCP שלם ב-TypeScript עם ה-SDK הרשמי: notes-server, שרת פתקים. יש בו שלושה כלים (create_note, search_notes ו-delete_note עם אישור משתמש), resource סטטי בשם notes://index, resource template בשם notes://{id} עם השלמה אוטומטית, ו-prompt אחד. אותו קוד רץ גם ב-stdio וגם ב-Streamable HTTP.
ה-SDK מטפל בכל מה שלמדנו בפרקים 2 עד 7: מעטפת JSON-RPC, בדיקת _meta וגרסאות, server/discover, המרת סכמות zod ל-JSON Schema, אימות קלט, resultType ו-MRTR. לאורך הפרק נקשר כל קריאה ל-SDK להודעה שהיא מייצרת ב-wire, כדי שיהיה ברור מה קורה מתחת.
הקוד בפרק נבדק מול @modelcontextprotocol/server בגרסה 2.1.0: הוא עובר קומפילציה, והודעות ה-JSON בפרק הזה ובפרק 7 הן פלט אמיתי שלו. ה-SDK מתפתח, ולכן כדאי לבדוק את ה-API העדכני ב-ts.sdk.modelcontextprotocol.io לפני שמתחילים.
הקמת הפרויקט
לפי מדריך בניית השרתים הרשמי, מתקינים את @modelcontextprotocol/server ואת zod, ובתור תלויות פיתוח את typescript ואת @types/node. ב-SDK בגרסה 2 החבילות מפוצלות: החבילה server מכילה את McpServer ואת ה-transports, והחבילה @modelcontextprotocol/node מוסיפה מתאם ל-HTTP של Node (בהמשך).
ב-package.json מגדירים type: module, כי ה-SDK הוא ESM. ב-tsconfig.json המדריך משתמש ב-module ו-moduleResolution של Node16, עם strict. הפקודות למטה מקימות את הפרויקט.
המבנה שנבנה: src/notes.ts עם factory בשם createNotesServer שמגדיר את כל הכלים, ה-resources וה-prompts. src/stdio.ts ו-src/http.ts מריצים את אותו factory בשני ה-transports. ההפרדה הזו חשובה, כי ה-SDK יוצר instance חדש מה-factory לכל בקשת HTTP.
mkdir notes-server && cd notes-server
npm init -y
npm install @modelcontextprotocol/server @modelcontextprotocol/node zod
npm install -D typescript @types/node
npm pkg set type=module
mkdir srcכלל הזהב של שרת stdio
לפני שכותבים שורת קוד אחת: בשרת stdio, stdout שייך לפרוטוקול (פרק 4). console.log כותב ל-stdout, ולכן console.log אחד באמצע handler מכניס לזרם שורה שאינה JSON-RPC, וה-client נכשל בפענוח או מתנתק. זו התקלה הנפוצה ביותר בשרתים חדשים, והיא קשה לאבחון, כי השרת עצמו לא קורס.
הפתרון: כל לוג הולך ל-stderr, עם console.error או ספריית לוגים שמוגדרת ל-stderr. זה כולל גם ספריות צד שלישי: ספרייה שמדפיסה אזהרה ל-stdout שוברת את השרת באותה צורה. בשרת HTTP הבעיה לא קיימת, אבל כדאי להרגיל את עצמכם לכתוב ל-stderr בכל מקרה.
בגלל אותה סיבה, לוגים לא עוברים דרך הפרוטוקול: ה-Logging של MCP הוא deprecated (פרק 7). stderr בשרתי stdio, ו-OpenTelemetry למעקב מובנה.
McpServer ו-registerTool
new McpServer מקבל את ה-Implementation של השרת, name ו-version, שיופיעו ב-serverInfo בכל תשובה (פרק 3). registerTool מקבל שם, אובייקט הגדרה, ו-handler. בהגדרה: title, description, inputSchema כ-z.object, outputSchema אופציונלי ו-annotations. ה-handler מקבל את הארגומנטים כבר מאומתים ומוקלדים, ומחזיר CallToolResult.
מה קורה ב-wire: ה-SDK ממיר את סכמות zod ל-JSON Schema 2020-12, כולל $schema, ומחזיר אותן ב-tools/list (הסעיף הבא מציג את הפלט האמיתי). כשמגיעה קריאה, הוא מאמת את ה-arguments מול הסכמה. ארגומנטים לא תקינים מחזירים שגיאת ביצוע עם isError ועם הודעה שמפרטת כל שדה, כפי שה-spec ממליץ, כדי שהמודל יוכל לתקן. כלי לא מוכר מחזיר JSON-RPC error עם -32602.
כשיש outputSchema, ה-handler מחזיר structuredContent וגם את אותו JSON כטקסט ב-content, בדיוק כפי שה-spec ממליץ לתאימות לאחור (פרק 5). ה-SDK מאמת גם את הפלט מול ה-outputSchema. שימו לב ל-annotations: create_note מסומן כלא הרסני, ו-search_notes כקריאה בלבד.
ה-Map של הפתקים מוגדר מחוץ ל-factory, כך שכל ה-instances חולקים אותו. בפרודקשן זה יהיה מסד נתונים. כל מצב שחייב לשרוד בין בקשות יושב מחוץ ל-instance, כי ה-instance עצמו חי לבקשה אחת.
import { McpServer, ResourceTemplate, ResourceNotFoundError, inputRequired, acceptedContent } from "@modelcontextprotocol/server";
import { randomUUID } from "node:crypto";
import { z } from "zod";
interface Note { id: string; title: string; body: string; createdAt: string }
// זיכרון משותף לכל ה-instances. בפרודקשן: מסד נתונים
const notes = new Map<string, Note>();
export function createNotesServer(): McpServer {
const server = new McpServer({ name: "notes-server", version: "1.0.0" });
server.registerTool(
"create_note",
{
title: "Create Note",
description: "Creates a note and returns its id. Read it as the resource notes://{id} or pass the id to delete_note.",
inputSchema: z.object({
title: z.string().min(1).max(120).describe("Short title"),
body: z.string().max(10_000).describe("Note content in Markdown"),
}),
outputSchema: z.object({ id: z.string() }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
async ({ title, body }) => {
const note = { id: `note_${randomUUID()}`, title, body, createdAt: new Date().toISOString() };
notes.set(note.id, note);
const output = { id: note.id };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
},
);
server.registerTool(
"search_notes",
{
title: "Search Notes",
description: "Case-insensitive search in note titles and bodies. Returns at most 20 matches.",
inputSchema: z.object({ query: z.string().min(1) }),
outputSchema: z.object({
matches: z.array(z.object({ id: z.string(), title: z.string() })),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ query }) => {
const q = query.toLowerCase();
const matches = [...notes.values()]
.filter((n) => n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q))
.slice(0, 20)
.map(({ id, title }) => ({ id, title }));
const output = { matches };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
},
);
// ... delete_note, ה-resource וה-prompt בהמשך הפרק
return server;
}מה ה-SDK שולח: הכלי ב-tools/list
זה הערך האמיתי ש-create_note מקבל ב-tools/list. ה-inputSchema וה-outputSchema נוצרו מ-zod, כולל $schema של 2020-12, minLength ו-maxLength מ-min() ו-max(), ו-description מ-describe(). ב-outputSchema ה-SDK הוסיף additionalProperties: false, כי z.object לא מתיר שדות נוספים כברירת מחדל.
שימו לב ל-description: היא אומרת למודל איך להשתמש ב-id שחוזר, כלומר לקרוא אותו כ-resource או להעביר אותו ל-delete_note. זה ה-handle מפרק 3: המודל הוא שמעביר את המזהה מקריאה לקריאה, ולכן התיאור צריך להסביר את זה.
את השרת עצמו מזהה ה-serverInfo שבתשובת server/discover. בגרסה שבדקנו, ה-SDK הצהיר אוטומטית על tools, resources ו-prompts עם listChanged, ועל completions, לפי מה שנרשם.
{
"name": "create_note",
"title": "Create Note",
"description": "Creates a note and returns its id. Read it as the resource notes://{id} or pass the id to delete_note.",
"inputSchema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"description": "Short title"
},
"body": {
"type": "string",
"maxLength": 10000,
"description": "Note content in Markdown"
}
},
"required": [
"title",
"body"
]
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
},
"outputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
],
"additionalProperties": false
}
}delete_note — MRTR ו-elicitation ב-SDK
delete_note מממש את פרק 7. ה-handler מקבל פרמטר שני, ctx, ובו ctx.mcpReq.inputResponses. בסבב הראשון אין תשובה, וה-handler מחזיר inputRequired עם בקשה מוטמעת שנבנתה ב-inputRequired.elicit. ה-SDK הופך את זה לתשובה עם resultType של input_required, זו שראינו בפרק 7. בסבב השני, acceptedContent מחזיר את ה-content של תשובה מאושרת.
נקודה עדינה: acceptedContent מחזיר undefined גם כשאין תשובה וגם כשהמשתמש סירב או ביטל. לכן ה-handler בודק קודם אם יש תשובה בכלל, ורק אז בודק אם היא אישור. גרסה ראשונה של הקוד הזה בדקה רק את acceptedContent, ומשתמש שסירב נשאל שוב ושוב. זו בדיוק הטעות שהזהרנו ממנה בפרק 7.
ה-SDK אוכף את כללי ה-capabilities בעצמו. אם ה-client לא הצהיר על elicitation, התשובה היא -32021 עם requiredCapabilities, ולא בקשת elicitation. requestState לא נדרש כאן, כי ה-client שולח את כל ה-arguments שוב. אם משתמשים בו, צריך להגן על השלמות שלו (פרק 7). התיעוד של ה-SDK מציין במפורש שהוא לא עושה את זה בשבילכם.
ה-annotations של delete_note: destructiveHint: true, כי המחיקה סופית, ו-idempotentHint: true, כי מחיקה שנייה של אותו id לא משנה דבר. לפי פרק 5, זה מה שמאפשר ל-client לשלוח את הבקשה שוב בבטחה אחרי stream שנקטע. תשובה לפתק שלא קיים היא isError עם הסבר איך למצוא ids.
// בתוך createNotesServer, אחרי search_notes
server.registerTool(
"delete_note",
{
title: "Delete Note",
description: "Permanently deletes a note. Asks the user to confirm first.",
inputSchema: z.object({ id: z.string() }),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
},
async ({ id }, ctx) => {
const note = notes.get(id);
if (!note) {
return { content: [{ type: "text", text: `No note with id ${id}. Use search_notes to find ids.` }], isError: true };
}
if (ctx.mcpReq.inputResponses?.["confirm"] === undefined) {
// סבב ראשון: אין עדיין תשובה, מבקשים אישור מהמשתמש (MRTR, פרק 7)
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Delete the note "${note.title}"? This cannot be undone.`,
requestedSchema: {
type: "object",
properties: { confirm: { type: "boolean", title: "Delete permanently" } },
required: ["confirm"],
},
}),
},
});
}
// decline, cancel או confirm: false: לא מוחקים ולא שואלים שוב
const answer = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, "confirm");
if (answer?.confirm !== true) {
return { content: [{ type: "text", text: "The user chose not to delete the note." }] };
}
notes.delete(id);
return { content: [{ type: "text", text: `Deleted note ${id}.` }] };
},
);resources ו-prompt
registerResource מקבל שתי צורות. עם מחרוזת URI הוא רושם resource סטטי: notes://index, תוכן עניינים של כל הפתקים, שמופיע תמיד ב-resources/list. ה-callback מקבל את ה-URI ומחזיר contents. עם ResourceTemplate הוא את התבנית notes://{id}, resource דינמי לכל פתק. ל-ResourceTemplate יש list, שמחזירה את ה-resources הקיימים ל-resources/list, ו-complete, שמחזירה השלמות למשתנה id ל-completion/complete (פרק 6). ה-callback של הקריאה מקבל את ה-URI ואת המשתנים שפורקו מהתבנית.
cacheHint קובע את ttlMs ואת cacheScope בתשובות resources/read: 5 שניות לאינדקס, שמשתנה עם כל פתק חדש, ו-10 שניות לפתק בודד. בשניהם private, כי הפתקים שייכים למשתמש. ב-resources/list ה-SDK מחזיר את ה-resource הסטטי ואחריו את מה ש-list של ה-template החזירה. פתק שלא קיים זורק ResourceNotFoundError, שה-SDK הופך ל-JSON-RPC error עם -32602 ו-data.uri, כפי שה-spec דורש. גרסה ראשונה של הקוד זרקה Error רגיל וקיבלה -32603, ולכן כדאי להשתמש במחלקות השגיאה של ה-SDK.
registerPrompt רושם את summarize_notes עם argsSchema ב-zod, שהופך ל-arguments ב-prompts/list. ה-callback מחזיר messages, שה-host יכניס לשיחה כשהמשתמש מפעיל את ה-prompt, למשל כ-slash command.
// בתוך createNotesServer, אחרי delete_note
server.registerResource(
"note",
new ResourceTemplate("notes://{id}", {
list: async () => ({
resources: [...notes.values()].map((n) => ({ uri: `notes://${n.id}`, name: n.id, title: n.title, mimeType: "text/markdown" })),
}),
complete: {
id: (value) => [...notes.keys()].filter((id) => id.startsWith(value)),
},
}),
{ title: "Note", description: "A single note as Markdown", mimeType: "text/markdown", cacheHint: { ttlMs: 10_000, cacheScope: "private" } },
async (uri, { id }) => {
const note = notes.get(String(id));
if (!note) throw new ResourceNotFoundError(uri.href); // -32602
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: `# ${note.title}\n\n${note.body}` }] };
},
);
// resource סטטי: URI קבוע, בלי משתנים
server.registerResource(
"notes-index",
"notes://index",
{ title: "All notes", description: "Table of contents of every note", mimeType: "text/markdown", cacheHint: { ttlMs: 5_000, cacheScope: "private" } },
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "text/markdown",
text: [...notes.values()].map((n) => `- [${n.title}](notes://${n.id})`).join("\n") || "No notes yet.",
}],
}),
);
server.registerPrompt(
"summarize_notes",
{
title: "Summarize notes about a topic",
description: "Finds notes about a topic and asks the model to summarize them",
argsSchema: z.object({ topic: z.string().describe("The topic to summarize") }),
},
({ topic }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Use search_notes to find every note about "${topic}", read them, and write a 5-bullet summary with note ids as citations.`,
},
},
],
}),
);
return server;
}הרצה ב-stdio
serveStdio מקבל את ה-factory ומנהל את החיבור. הוא מזהה את הדור מההודעה הראשונה ב-stdin: server/discover או בקשה עם _meta מודרני פותחים חיבור מודרני, ו-initialize פותח session בסגנון 2025-11-25. בשני המקרים instance אחד מה-factory מוצמד לחיבור. אותו שרת עובד כך גם עם hosts ישנים, בלי קוד נוסף.
המדריך הרשמי מציג גם את הדרך הישירה: new StdioServerTransport() ו-server.connect(transport). serveStdio הוא הבחירה הנוחה כשרוצים לתמוך בשני הדורות מאותו factory.
אחרי npx tsc, ה-host מפעיל את build/stdio.js כתהליך-משנה. בקונפיגורציה מפרק 4 זה command של node ו-args עם הנתיב לקובץ. credentials, אם יש, עוברים ב-env ולא ב-OAuth.
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createNotesServer } from "./notes.js";
serveStdio(() => createNotesServer());
console.error("notes-server running on stdio"); // לוגים רק ל-stderrהרצה ב-Streamable HTTP
createMcpHandler מקבל את ה-factory ויוצר handler בסגנון fetch: Request נכנס, Response יוצא. זה מתאים ל-Workers, Deno ו-Bun, וב-Node עוטפים אותו ב-toNodeHandler מ-@modelcontextprotocol/node. ה-factory נקרא לכל בקשה, וזו המשמעות המעשית של stateless: אין instance שחי לאורך זמן.
ה-handler עצמו לא בודק Host או Origin, וזה מתועד במפורש ב-SDK. לכן, לפי פרק 4, שמים לפניו הגנה מ-DNS rebinding: localhostHostValidation ו-localhostOriginValidation מחזירות false ועונות 403 כשהבקשה לא מגיעה מ-localhost. והשרת מאזין ל-127.0.0.1 בלבד. בשרת מרוחק מחליפים אותן ב-hostHeaderValidation ו-originValidation עם רשימת hosts מותרים, ומוסיפים אימות (פרק 10).
legacy: stateless, ברירת המחדל, משרת גם clients של 2025-11-25: הם מקבלים instance טרי לכל בקשה ותשובה ל-initialize, ו-GET או DELETE מקבלים 405. legacy: reject הופך את ה-endpoint למודרני בלבד, ו-clients ישנים מקבלים -32022 עם רשימת הגרסאות הנתמכות. ה-handler בודק גם את כותרות Mcp-Method ו-Mcp-Name מול הגוף, ומחזיר -32020 כשהן לא תואמות.
כל ההתנהגויות האלה נבדקו מול השרת הזה עם curl: בקשה מודרנית עם הכותרות הנכונות מקבלת 200, Origin זר מקבל 403, Mcp-Name שגוי מקבל 400 עם -32020, initialize ישן מקבל תשובה בסגנון 2025-11-25, ו-GET מקבל 405.
import { createServer } from "node:http";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from "@modelcontextprotocol/node";
import { createNotesServer } from "./notes.js";
// factory: instance חדש לכל בקשה, כי הפרוטוקול stateless
const handler = createMcpHandler(() => createNotesServer(), { legacy: "stateless" });
const serveMcp = toNodeHandler(handler);
const validHost = localhostHostValidation();
const validOrigin = localhostOriginValidation();
createServer((req, res) => {
if (req.url !== "/mcp") {
res.writeHead(404).end();
return;
}
// ה-handler עצמו לא בודק Host/Origin: מגינים מ-DNS rebinding לפניו
if (!validHost(req, res) || !validOrigin(req, res)) return;
void serveMcp(req, res);
}).listen(3000, "127.0.0.1", () => console.error("notes-server on http://127.0.0.1:3000/mcp"));בדיקה ודיבאג
MCP Inspector הוא כלי הבדיקה הרשמי. npx @modelcontextprotocol/inspector פותח ממשק web שבו מגדירים את השרת (פקודת stdio או URL של HTTP), רואים את הכלים, ה-resources וה-prompts, מפעילים כלים עם ארגומנטים ורואים את ההודעות הגולמיות. יש גם מצב --cli לבדיקות אוטומטיות ומצב --tui לממשק טקסט. בדקו בתיעוד של ה-Inspector את הדגלים המדויקים לגרסה שלכם.
אפשר גם לדבר עם שרת stdio ישירות מה-shell, וזה מדגים בצורה הכי ברורה שמדובר בשורות JSON: שולחים שורה אחת עם server/discover ל-stdin, ומקבלים שורה אחת ב-stdout. הפקודה למטה עושה את זה. כך גם בודקים שאין שום דבר אחר שנכתב ל-stdout.
ולבסוף מחברים את השרת ל-host אמיתי לפי הקונפיגורציה שלו, ובודקים את מה שאף כלי בדיקה לא יבדוק בשבילכם: האם המודל מבין מתי להשתמש בכל כלי, לפי ה-description בלבד. אם המודל מבלבל בין כלים או מעביר ארגומנטים שגויים, הבעיה בדרך כלל בתיאור ולא בקוד.
echo '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | node build/stdio.jsתשובת server/discover של השרת
זו התשובה האמיתית של notes-server ל-server/discover, כפי שנקלטה ב-stdio. supportedVersions מכיל רק את 2026-07-28, כי זה החיבור המודרני. את 2025-11-25 השרת משרת דרך מסלול ה-initialize הנפרד.
ה-capabilities נגזרו אוטומטית ממה שנרשם: tools, resources ו-prompts עם listChanged, ו-completions בגלל ה-complete של ה-ResourceTemplate. ttlMs הוא 0 ו-cacheScope הוא private, כלומר בגרסה שבדקנו ה-SDK לא מציע cache לתשובה הזו כברירת מחדל. זה הערך הזהיר, והוא חוקי לפי ה-spec.
ה-serverInfo ב-_meta נלקח מה-constructor של McpServer. כל תשובה אחרת של השרת נושאת אותו גם כן, כפי שראינו בתשובות בפרק 7.
{
"result": {
"supportedVersions": [
"2026-07-28"
],
"capabilities": {
"tools": {
"listChanged": true
},
"completions": {},
"resources": {
"listChanged": true
},
"prompts": {
"listChanged": true
}
},
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "notes-server",
"version": "1.0.0"
}
}
},
"jsonrpc": "2.0",
"id": 1
}צ'קליסט לפני פרודקשן
קלט ופלט: כל כלי עם inputSchema מדויק (אורכים, טווחים, enums), ו-outputSchema כשהפלט מובנה. שגיאות שהמודל יכול לתקן חוזרות כ-isError עם הסבר, ולא כ-exception. ושום דבר רגיש (סודות, tokens, מידע אישי שלא נדרש) לא יוצא ב-content, ב-structuredContent או בלוגים.
אמינות: timeout לכל קריאה החוצה (ל-API, למסד נתונים) שקצר מה-timeout של ה-client. כלים שעלולים להישלח שוב אחרי ניתוק (פרק 4) צריכים להיות idempotent, או לקבל מפתח idempotency. ו-rate limiting לכל משתמש, כפי שה-spec דורש מכל שרת שחושף כלים.
הרשאות: ה-credentials של השרת מקבלים רק את ההרשאות שהכלים באמת צריכים (least privilege). כלי קריאה לא צריך token שיכול למחוק. requestState מוגן ב-HMAC או AEAD כשהוא משפיע על החלטות, ושרת HTTP מרוחק מממש Authorization (פרק 10).
יציבות לממשק: שמות כלים יציבים בסדר קבוע, תיאורים שנבדקו מול מודל אמיתי, ושינוי רשימת הכלים רק כשיש סיבה. כל שינוי כזה מבטל את ה-cache של ה-clients ואת ה-prompt cache אצל ספק המודל, וכלי שנעלם באמצע שיחה מבלבל את המודל.