ساخت MCP Server اختصاصی

قسمت ۳ ۲۰ دقیقه

خلاصه قسمت قبل

در قسمت ۲ اولین MCP Server خودمون رو ساختیم — یه ابزار ساعت و یه محاسبه‌گر BMI. یاد گرفتیم چطور SDK رو نصب کنیم، سرور رو بسازیم و به Claude Desktop وصلش کنیم. حالا وقتشه یه قدم بزرگ‌تر برداریم و یه سرور واقعی و حرفه‌ای بسازیم.

از نمونه به واقعیت

ابزارهای قسمت قبل عالی بودن برای شروع، ولی توی دنیای واقعی نیازهای پیچیده‌تری داری. می‌خوای سرور چندین ابزار داشته باشه، هر ابزار ورودی‌های مختلف بگیره، خطاها رو درست هندل کنه و ساختار کدش تمیز باشه. این قسمت قراره همه اینا رو پوشش بده.

می‌خوایم یه MCP Server بسازیم که به یه فایل‌سیستم دسترسی داره — می‌تونه فایل‌ها رو لیست کنه، محتواشون رو بخونه و حتی فایل جدید بسازه. یه پروژه واقعی که می‌تونی بعداً گسترشش بدی.

پیش‌نیاز
این قسمت ادامه قسمت ۲ هست. باید با ساخت MCP Server ساده آشنا باشی. اگه قسمت قبل رو نخوندی، پیشنهاد می‌کنم اول اون رو ببینی.

ساختار یه MCP Server حرفه‌ای

قبل از نوشتن کد، بذار درباره ساختار حرف بزنیم. یه MCP Server خوب از سه بخش اصلی تشکیل شده:

۱. تعریف سرور (Server Definition): اسم، نسخه و اطلاعات کلی سرور. این بخش به Client می‌گه با چی طرفه.

۲. تعریف ابزارها (Tool Definitions): هر ابزار یه اسم، توضیح، شمای ورودی و تابع اجرا داره. این مهم‌ترین بخشه — اینجاست که می‌گی سرورت چی‌کار بلده.

۳. Transport و اتصال: روش ارتباط سرور با Client. فعلاً Stdio استفاده می‌کنیم ولی MCP از HTTP/SSE هم پشتیبانی می‌کنه.

تشبیه
فکر کن داری یه فروشگاه آنلاین می‌سازی. تعریف سرور مثل اسم فروشگاهته. ابزارها مثل محصولاتتن — هر کدوم توضیح، مشخصات و قیمت داره. Transport هم مثل سرویس پستیه که سفارش‌ها رو جابه‌جا می‌کنه.

ساخت سرور فایل‌سیستم

بیا شروع کنیم. یه پوشه جدید بساز:

mkdir mcp-filesystem-server
cd mcp-filesystem-server
npm init -y
npm install @modelcontextprotocol/sdk

یادت نره "type": "module" رو به package.json اضافه کنی.

حالا فایل index.js رو بساز. از بالا شروع می‌کنیم:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import fs from "fs/promises";
import path from "path";

const ALLOWED_DIR = process.env.MCP_ALLOWED_DIR || process.cwd();

const server = new McpServer({
  name: "filesystem-server",
  version: "1.0.0"
});

نکته مهم اینجا ALLOWED_DIR هست. داریم مشخص می‌کنیم سرور فقط به یه پوشه خاص دسترسی داشته باشه. این یه اصل امنیتی مهمه — هیچ‌وقت نباید یه MCP Server به کل فایل‌سیستم دسترسی داشته باشه.

ابزار اول: لیست فایل‌ها

اولین ابزارمون فایل‌های یه پوشه رو لیست می‌کنه:

server.tool(
  "list_files",
  "List files and directories in a given path",
  {
    dirPath: {
      type: "string",
      description: "Relative path to the directory (default: root)"
    }
  },
  async ({ dirPath = "" }) => {
    const fullPath = path.resolve(ALLOWED_DIR, dirPath);

    if (!fullPath.startsWith(ALLOWED_DIR)) {
      return {
        content: [{ type: "text", text: "Error: Access denied. Path is outside allowed directory." }],
        isError: true
      };
    }

    const entries = await fs.readdir(fullPath, { withFileTypes: true });
    const result = entries.map(e =>
      `${e.isDirectory() ? "📁" : "📄"} ${e.name}`
    ).join("\n");

    return {
      content: [{ type: "text", text: result || "Empty directory" }]
    };
  }
);

چند نکته مهم توی این کد هست:

بررسی امنیتی: خط if (!fullPath.startsWith(ALLOWED_DIR)) مطمئن می‌شه کسی نتونه با ../ از پوشه مجاز خارج بشه. این خیلی مهمه — بدون این چک، AI می‌تونه به هر فایلی روی سیستمت دسترسی داشته باشه.

isError: وقتی خطا داریم، isError: true برمی‌گردونیم. این به AI می‌گه عملیات موفق نبوده و باید به کاربر اطلاع بده.

withFileTypes: این آپشن به readdir می‌گه نوع هر آیتم (فایل یا پوشه) رو هم برگردونه.

ابزار دوم: خوندن فایل

server.tool(
  "read_file",
  "Read the contents of a file",
  {
    filePath: {
      type: "string",
      description: "Relative path to the file"
    }
  },
  async ({ filePath }) => {
    const fullPath = path.resolve(ALLOWED_DIR, filePath);

    if (!fullPath.startsWith(ALLOWED_DIR)) {
      return {
        content: [{ type: "text", text: "Error: Access denied." }],
        isError: true
      };
    }

    try {
      const content = await fs.readFile(fullPath, "utf-8");
      return {
        content: [{ type: "text", text: content }]
      };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Error reading file: ${err.message}` }],
        isError: true
      };
    }
  }
);

اینجا try/catch اضافه کردیم. چرا؟ چون ممکنه فایل وجود نداشته باشه یا دسترسی نباشه. یه MCP Server خوب باید خطاها رو مدیریت کنه و پیام واضح برگردونه — نه اینکه crash کنه.

مهم
هرگز خطاهای سیستمی رو مستقیم به AI نفرست. اطلاعات حساس مثل مسیر کامل فایل‌ها، اطلاعات سیستم‌عامل یا stack trace ممکنه لو بره. پیام خطا رو فیلتر کن و فقط اطلاعات لازم رو برگردون.

ابزار سوم: نوشتن فایل

server.tool(
  "write_file",
  "Create or overwrite a file with the given content",
  {
    filePath: {
      type: "string",
      description: "Relative path for the file"
    },
    content: {
      type: "string",
      description: "Content to write to the file"
    }
  },
  async ({ filePath, content }) => {
    const fullPath = path.resolve(ALLOWED_DIR, filePath);

    if (!fullPath.startsWith(ALLOWED_DIR)) {
      return {
        content: [{ type: "text", text: "Error: Access denied." }],
        isError: true
      };
    }

    await fs.mkdir(path.dirname(fullPath), { recursive: true });
    await fs.writeFile(fullPath, content, "utf-8");

    return {
      content: [{ type: "text", text: `File written successfully: ${filePath}` }]
    };
  }
);

نکته جدید اینجا fs.mkdir با recursive: true هست. اگه پوشه‌های میانی وجود نداشته باشن، خودکار ساخته می‌شن. مثلاً اگه بگی فایل رو توی reports/2026/may/data.txt بنویس، همه پوشه‌ها ساخته می‌شن.

تعریف شماهای ورودی دقیق

یکی از مهم‌ترین بخش‌های ساخت یه MCP Server خوب، تعریف دقیق شماهای ورودیه. شما (Schema) به AI می‌گه هر ابزار چه ورودی‌هایی می‌گیره، چه نوعی هستن و چه محدودیت‌هایی دارن.

چرا مهمه؟ چون AI از روی description و نوع فیلدها تصمیم می‌گیره چطور ابزار رو استفاده کنه. اگه توضیحات ضعیف باشن، AI ممکنه اشتباه فراخوانی کنه.

بیا یه مثال پیشرفته‌تر ببینیم — یه ابزار جستجو توی فایل‌ها:

server.tool(
  "search_files",
  "Search for files matching a pattern in their name or content",
  {
    query: {
      type: "string",
      description: "Search term to look for in file names and content"
    },
    searchIn: {
      type: "string",
      description: "Where to search: 'name' for file names only, 'content' for file content only, 'both' for both",
      enum: ["name", "content", "both"]
    },
    maxResults: {
      type: "number",
      description: "Maximum number of results to return (default: 10)"
    }
  },
  async ({ query, searchIn = "both", maxResults = 10 }) => {
    // ... implementation
  }
);

ببین چطور هر فیلد یه description واضح داره. فیلد searchIn از enum استفاده می‌کنه تا AI فقط بین مقادیر مجاز انتخاب کنه. maxResults مقدار پیش‌فرض داره — اگه کاربر نگفت، ۱۰ تا نتیجه برمی‌گرده.

نکته
توضیحات ابزار و فیلدها رو به انگلیسی بنویس. مدل‌های زبانی با متن انگلیسی بهتر کار می‌کنن و احتمال فراخوانی صحیح بالاتر می‌ره. حتی اگه کاربر فارسی حرف بزنه، AI توضیحات انگلیسی رو بهتر می‌فهمه.

مدیریت خطا — حرفه‌ای

یه سرور حرفه‌ای باید خطاها رو جدی بگیره. بذار یه تابع کمکی بسازیم:

function safeResult(text) {
  return { content: [{ type: "text", text }] };
}

function errorResult(message) {
  return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
}

function validatePath(filePath) {
  const fullPath = path.resolve(ALLOWED_DIR, filePath);
  if (!fullPath.startsWith(ALLOWED_DIR)) {
    throw new Error("Access denied: path outside allowed directory");
  }
  return fullPath;
}

حالا می‌تونی ابزارها رو تمیزتر بنویسی:

server.tool("read_file", "Read file contents", {
  filePath: { type: "string", description: "Relative file path" }
}, async ({ filePath }) => {
  try {
    const fullPath = validatePath(filePath);
    const content = await fs.readFile(fullPath, "utf-8");
    return safeResult(content);
  } catch (err) {
    return errorResult(err.message);
  }
});

خیلی تمیزتر شد، نه؟ هر ابزار فقط منطق خودش رو داره و بقیه چیزها centralize شدن.

تشبیه
فکر کن هر ابزار یه کارمند فروشگاهه. تابع validatePath مثل نگهبان دم دره — هر درخواست اول از اون رد می‌شه. safeResult و errorResult هم مثل فرم‌های استاندارد فروشگاهن — همه کارمندها از یه فرم استفاده می‌کنن.

وصل کردن و تست

آخر فایل، transport رو اضافه کن:

const transport = new StdioServerTransport();
await server.connect(transport);

و فایل config رو آپدیت کن:

{
  "mcpServers": {
    "filesystem-server": {
      "command": "node",
      "args": ["/path/to/mcp-filesystem-server/index.js"],
      "env": {
        "MCP_ALLOWED_DIR": "/path/to/your/allowed/folder"
      }
    }
  }
}

توجه کن که env رو اضافه کردیم تا متغیر محیطی MCP_ALLOWED_DIR رو ست کنیم. اینطوری بدون تغییر کد، می‌تونی مشخص کنی سرور به کدوم پوشه دسترسی داشته باشه.

Claude Desktop رو ریستارت کن و تست کن:

  • «فایل‌های توی پوشه رو لیست کن»
  • «محتوای فایل README.md رو بخون»
  • «یه فایل notes.txt بساز و توش بنویس سلام»

چی یاد گرفتیم؟

این قسمت پر از نکته‌های عملی بود:

  • ساختار سرور: تعریف سرور + ابزارها + transport
  • امنیت: محدود کردن دسترسی به یه پوشه خاص
  • چندین ابزار: یه سرور می‌تونه هر تعداد ابزار داشته باشه
  • شمای دقیق: description خوب + enum + مقدار پیش‌فرض
  • مدیریت خطا: try/catch + پیام‌های واضح + isError
  • متغیر محیطی: config بدون تغییر کد
قسمت بعدی
توی قسمت ۴: Resources و Prompts در MCP با دو قابلیت دیگه MCP Server آشنا می‌شیم. یاد می‌گیری چطور داده‌ها رو مستقیم در اختیار AI بذاری (Resources) و الگوهای تعامل از پیش تعریف‌شده بسازی (Prompts). اینا دقیقاً همون چیزین که سرورت رو از «خوب» به «عالی» تبدیل می‌کنن.

نظرات

هنوز نظری ثبت نشده. اولین نفر باشید!

نظر خود را بنویسید