← MCP in Practice: Building Model Context Protocol Servers
03 / 07 · 60 min

Your First Server with the TypeScript SDK

Build a working MCP server from scratch: McpServer, registerTool with zod schemas, resources, prompts, and StdioServerTransport.

Lesson content is currently available in Russian.

What you will learn

  • →Настраивать проект: ESM, tsconfig, зависимости @modelcontextprotocol/sdk и zod.
  • →Регистрировать инструменты, ресурсы и промпты через высокоуровневый API McpServer.
  • →Запускать сервер на StdioServerTransport и соблюдать дисциплину stdout/stderr.

Каркас проекта

Официальный TypeScript SDK — пакет @modelcontextprotocol/sdk. Он реализует весь протокол: рукопожатие, маршрутизацию методов, валидацию, оба транспорта. Ваша работа — только доменная логика. Схемы аргументов описываются через zod, поэтому ставим обе зависимости:

mkdir orders-mcp && cd orders-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

SDK распространяется как ES-модули, поэтому в package.json обязательно "type": "module", а в tsconfig.json — резолюция NodeNext:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

Обратите внимание на суффиксы .js в импортах ниже — при NodeNext-резолюции это требование ESM, а не опечатка: TypeScript-файл импортирует путь так, как он будет выглядеть после компиляции.

Структура минимальна: один src/index.ts на старте, позже разнесёте инструменты по файлам. Скрипт сборки — обычный tsc; никакого бандлера для stdio-сервера не нужно.

McpServer и первый инструмент

Высокоуровневый класс McpServer скрывает JSON-RPC полностью. Регистрируете возможности — SDK сам отвечает на tools/list, валидирует аргументы и упаковывает результат:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

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

server.registerTool(
  'search_orders',
  {
    title: 'Поиск заказов',
    description: 'Ищет заказы по статусу. Возвращает не более limit записей (по умолчанию 20).',
    inputSchema: {
      status: z.enum(['new', 'paid', 'shipped']),
      limit: z.number().int().min(1).max(100).optional(),
    },
  },
  async ({ status, limit }) => {
    const orders = await db.findOrders(status, limit ?? 20);
    return { content: [{ type: 'text', text: JSON.stringify(orders) }] };
  }
);

Здесь работают сразу три механизма. Во-первых, zod-схема автоматически конвертируется в JSON Schema для tools/list — модель увидит и enum, и ограничения. Во-вторых, аргументы валидируются до вызова хендлера: если модель передаст status: "cancelled", хендлер даже не запустится, а клиент получит ошибку invalid params. В-третьих, внутри хендлера аргументы уже типизированы — TypeScript выведет status: 'new' | 'paid' | 'shipped'.

Описание инструмента — это промпт-инжиниринг: модель выбирает инструмент и заполняет аргументы, читая именно description. Пишите его для модели, а не для человека: что делает, что возвращает, какие ограничения.

Ресурсы и промпты

Ресурс регистрируется с URI или шаблоном URI. Для семейства «карточка заказа по id» используем ResourceTemplate:

import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';

server.registerResource(
  'order',
  new ResourceTemplate('orders://{orderId}', { list: undefined }),
  {
    title: 'Заказ',
    description: 'Карточка заказа по идентификатору',
    mimeType: 'application/json',
  },
  async (uri, { orderId }) => ({
    contents: [{ uri: uri.href, text: JSON.stringify(await db.getOrder(String(orderId))) }],
  })
);

SDK сам отвечает на resources/templates/list, а при resources/read разбирает URI по шаблону и передаёт переменные в колбэк. Чтение ресурса обязано быть без побочных эффектов — это контракт протокола, на который полагается host.

Промпт — заготовленный сценарий, который пользователь запустит из интерфейса клиента:

server.registerPrompt(
  'weekly-report',
  {
    title: 'Недельный отчёт',
    description: 'Сводка по заказам за неделю',
    argsSchema: { week: z.string().describe('Номер недели, например 2026-W30') },
  },
  ({ week }) => ({
    messages: [{
      role: 'user',
      content: {
        type: 'text',
        text: 'Собери отчёт по заказам за неделю ' + week + ': выручка, топ-товары, проблемные доставки.',
      },
    }],
  })
);

Заметьте: текст собран конкатенацией, а не шаблонной строкой — дело вкуса, но важно, что промпт возвращает готовые messages, которые клиент подставит в разговор от имени пользователя.

Транспорт, запуск и дисциплина stdout

Осталось подключить транспорт. Для локального сервера это три строки в конце src/index.ts:

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error('[orders-mcp] server started on stdio');
}

main().catch((error) => {
  console.error('[orders-mcp] fatal:', error);
  process.exit(1);
});

После npm run build сервер запускается командой node dist/index.js — и молча ждёт кадры на stdin. Это правильное поведение: stdio-сервер не печатает приветствий в stdout, потому что stdout целиком принадлежит протоколу.

Правила, которые сэкономят вам часы отладки:

  • Стартовое сообщение и все логи — строго console.error (stderr). Один console.log — и клиент рвёт соединение на parse error.
  • Проверьте зависимости: некоторые библиотеки печатают баннеры в stdout при импорте. Такие — изолировать или заглушать.
  • Ошибки доменного уровня возвращайте как { content: [...], isError: true }, а не бросайте исключение: брошенное исключение SDK превратит в ошибку вызова, но модель получит меньше контекста для восстановления.
  • Процесс не должен писать в stdout даже при завершении — никаких прощальных сообщений.

Полноценно пощупать сервер руками мы сможем в уроке про Inspector — а пока убедитесь, что процесс запускается и не падает.

Practice

Сервер заметок notes-mcp

Соберите с нуля stdio-сервер notes-mcp: инструмент add_note (title, text) с zod-валидацией, инструмент search_notes (query, limit), ресурс notes://all со списком заметок и промпт summarize-notes. Хранилище — обычный Map в памяти. Соблюдайте дисциплину stderr.

Deliverable

Репозиторий с собирающимся через tsc сервером: два инструмента, один ресурс, один промпт; node dist/index.js запускается без вывода в stdout.

Checkpoint

Покажите свой registerTool и объясните, что произойдёт, если модель передаст аргументы, не проходящие zod-схему. Дойдёт ли вызов до вашего хендлера?

  • ✓ Указано, что SDK валидирует аргументы по схеме до хендлера и возвращает ошибку invalid params.
  • ✓ Объяснено, что внутри хендлера аргументы уже типизированы согласно zod-схеме.
  • ✓ Различены ошибка валидации (протокольная) и доменная ошибка через isError: true.

Check yourself

Зачем inputSchema в registerTool описывается через zod?

01Зачем inputSchema в registerTool описывается через zod?

Что должен вернуть хендлер инструмента при успехе?

02Что должен вернуть хендлер инструмента при успехе?

Почему стартовое сообщение сервера пишется через console.error, а не console.log?

03Почему стартовое сообщение сервера пишется через console.error, а не console.log?

Что делает вызов server.connect(transport)?

04Что делает вызов server.connect(transport)?

Почему в импортах SDK стоит суффикс .js, хотя код на TypeScript?

05Почему в импортах SDK стоит суффикс .js, хотя код на TypeScript?

Sources

Your First Server with the TypeScript SDK · MCP in Practice: Building Model Context Protocol Servers · VORCL