Свой MCP-сервер за 30 минут: пошаговый туториал

Пошаговый MCP-туториал в 2026: пишем локальный stdio-сервер на TypeScript, тестируем через Inspector и готовим его к переходу на Streamable HTTP.

В этом туториале мы сделаем нормальный первый MCP-сервер в current 2026 рамке: не перегруженный remote deployment, не catalogue of every feature, а понятный local stdio server на TypeScript, который можно проверить через Inspector и потом без боли подготовить к Streamable HTTP.

Это сознательно лучший старт, чем сразу прыгать в hosted remote server. Для первого MCP-проекта важнее понять lifecycle, contracts и test path, чем строить сложную инфраструктуру.

MCP-сервер — это способ дать AI-приложению новые способности. В этом туториале мы сделаем маленький сервер заметок: модель сможет создавать заметку, искать заметки и читать статистику. Этого достаточно, чтобы понять, как выглядят tools, resources, transport и тестирование.
Мы не начинаем с remote deployment, OAuth и сложного продакшен-хостинга. Это не потому, что они не важны, а потому что для первого сервера они создают лишнюю сложность и отвлекают от core primitives.

Короткая версия

Что мы построим:

  • локальный MCP-сервер notes-server;
  • transport: stdio;
  • tools: создание и поиск заметок;
  • resource: сводка по заметкам;
  • test path через Inspector.

Что вы поймёте после туториала:

  • как выглядит минимальный McpServer;
  • как регистрировать tools и resources;
  • как подключать StdioServerTransport;
  • как руками проверять capabilities и ответы;
  • что нужно поменять позже для Streamable HTTP.
ПромптClaude Desktop + Notes MCP
Создай заметку 'Идеи для MCP' с тегом 'infra', потом найди все заметки по тегу 'infra'.
Ответ модели

Создаю заметку... → Note created: Идеи для MCP

Ищу заметки... → Найдено 3 заметки с тегом infra, включая новую запись.

Плохой первый шаг
Сразу строить remote MCP service, auth, hosting и сложный API surface.
Нормальный первый шаг
Сначала сделать один маленький stdio-сервер, проверить его через Inspector и только потом думать о remote transport.

Шаг 1. Подготовьте проект

Нужны:

  • Node.js 18+;
  • npm;
  • базовое понимание TypeScript.

Создайте проект:

mkdir mcp-notes-server
cd mcp-notes-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init

Обновите tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

И добавьте скрипты в package.json:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Шаг 2. Создайте минимальный сервер

Создайте src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

type Note = {
  id: string;
  title: string;
  content: string;
  tags: string[];
  createdAt: string;
};

const DATA_DIR = join(process.cwd(), ".data");
const NOTES_FILE = join(DATA_DIR, "notes.json");

if (!existsSync(DATA_DIR)) {
  mkdirSync(DATA_DIR, { recursive: true });
}

function loadNotes(): Note[] {
  if (!existsSync(NOTES_FILE)) return [];
  return JSON.parse(readFileSync(NOTES_FILE, "utf-8"));
}

function saveNotes(notes: Note[]) {
  writeFileSync(NOTES_FILE, JSON.stringify(notes, null, 2));
}

function makeId() {
  return Math.random().toString(36).slice(2, 10);
}

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

Это уже рабочая основа: storage плюс McpServer.

Шаг 3. Добавьте tools

Сначала сделаем два полезных инструмента: создание и поиск.

server.tool(
  "create_note",
  "Create a note with title, content, and optional tags.",
  {
    title: z.string(),
    content: z.string(),
    tags: z.array(z.string()).optional(),
  },
  async ({ title, content, tags }) => {
    const notes = loadNotes();
    const note: Note = {
      id: makeId(),
      title,
      content,
      tags: tags ?? [],
      createdAt: new Date().toISOString(),
    };

    notes.push(note);
    saveNotes(notes);

    return {
      content: [
        {
          type: "text",
          text: `Note created: ${note.title} (${note.id})`,
        },
      ],
    };
  }
);

server.tool(
  "search_notes",
  "Search notes by keyword or tag. Use when the user asks to find existing notes.",
  {
    query: z.string().optional(),
    tag: z.string().optional(),
  },
  async ({ query, tag }) => {
    let notes = loadNotes();

    if (query) {
      const q = query.toLowerCase();
      notes = notes.filter(
        (note) =>
          note.title.toLowerCase().includes(q) ||
          note.content.toLowerCase().includes(q)
      );
    }

    if (tag) {
      notes = notes.filter((note) => note.tags.includes(tag));
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(notes.slice(0, 10), null, 2),
        },
      ],
    };
  }
);

Обратите внимание на descriptions. Для MCP tools это не косметика, а часть контракта с моделью.

Шаг 4. Добавьте resource

Теперь дадим модели read-only summary.

server.resource(
  "notes://summary",
  "Summary information about saved notes",
  async (uri) => {
    const notes = loadNotes();
    const tagCounts = notes.reduce<Record<string, number>>((acc, note) => {
      for (const tag of note.tags) {
        acc[tag] = (acc[tag] ?? 0) + 1;
      }
      return acc;
    }, {});

    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(
            {
              totalNotes: notes.length,
              tags: tagCounts,
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

Это хороший пример, где resource лучше, чем tool: никакого side effect, просто контекст для чтения.

Шаг 5. Подключите transport

Для первого сервера берём stdio.

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

Полный файл src/index.ts на этом этапе уже достаточен для первого запуска.

Соберите проект:

npm run build

Шаг 6. Протестируйте через Inspector

Это лучший следующий шаг, чем сразу подключать Claude Desktop.

Inspector позволяет:

  • увидеть список capabilities;
  • вызвать tools руками;
  • прочитать resources;
  • быстро поймать schema/output ошибки.

Типовой flow:

  1. соберите проект;
  2. откройте Inspector;
  3. укажите команду запуска node dist/index.js;
  4. проверьте create_note, search_notes, notes://summary.

Если сервер странно ведёт себя в host, а в Inspector всё уже ломается, значит проблема почти точно в вашем server surface, а не в клиенте.

Шаг 7. Подключите к host

После Inspector можно идти в Claude Desktop или другой MCP-compatible host.

Логика конфигурации везде примерно одна:

  • host знает, как запускать ваш сервер;
  • для local server это обычно команда node dist/index.js;
  • transport остаётся stdio.

Главное здесь не конкретный путь к JSON-конфигу, а то, что first connection вы делаете уже после ручной проверки capabilities.

Шаг 8. Что поменять потом для Streamable HTTP

Когда local server уже работает, следующий шаг может быть remote path.

Обычно это нужно, если:

  • сервером должны пользоваться несколько людей;
  • нужен централизованный доступ к internal systems;
  • subprocess-local mode уже неудобен;
  • требуется service-style deployment.

Практически это означает:

  • другой transport;
  • другой hosting model;
  • отдельный security and auth layer;
  • более серьёзный observability path.

Но core server contracts при этом должны остаться теми же. Именно поэтому полезно сначала сделать хороший stdio server.

Что чаще всего ломают в первом туториале

  1. Делают слишком много tools сразу.
  2. Пишут расплывчатые descriptions.
  3. Возвращают giant unstructured blob вместо короткого результата.
  4. Пропускают Inspector и начинают дебажить уже в host.
  5. Сразу прыгают в remote deployment, не поняв local lifecycle.

Плюсы

  • Даёт правильный local-first старт
  • Показывает разницу между tools и resources
  • Встраивает Inspector в нормальный dev loop
  • Не перегружает читателя premature infra complexity
  • Готовит базу для дальнейшего перехода на Streamable HTTP

Минусы

  • Не покрывает полный remote hosting stack
  • Не решает auth и enterprise governance
  • Использует toy storage вместо production DB
  • Не показывает advanced protocol features вроде roots/sampling
  • Требует второго шага, если нужен shared remote server

Минимальный итоговый файл

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

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

// server.tool(...)
// server.resource(...)

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

Если вы поняли этот skeleton, дальше уже вопрос не в магии MCP, а в качестве ваших capabilities.

Следующие улучшения

После этого tutorial path обычно добавляют:

  1. лучшую storage layer;
  2. logging;
  3. tighter validation;
  4. Inspector-based regression checks;
  5. переход на Streamable HTTP, если server должен стать remote.
ПромптMCP tutorial reviewer
Когда после первого stdio-сервера уже пора в Streamable HTTP?
Ответ модели

Когда сервер перестал быть локальным инструментом одного разработчика и превратился в shared capability layer для нескольких пользователей, runtimes или сервисов.

Проверьте себя

Проверьте себя

1. Почему для первого MCP tutorial лучше `stdio`, а не сразу remote transport?

2. Когда `resource` лучше, чем `tool`, в нашем tutorial?

3. Почему Inspector стоит использовать до подключения к реальному host?