1. Что сегодня построим и как это вписывается в приложение
Вспомним наше учебное приложение: мы делаем ассистента по выбору подарков. В предыдущих модулях у нас уже был:
- виджет в ChatGPT (Next.js 16 + Apps SDK), который показывает UI, состояние и умеет дергать callTool;
- простой backend (через Apps SDK / роуты Next.js), который возвращал заглушки подарков.
Сейчас мы хотим «вынести мозги» нашего ассистента в отдельный MCP‑сервер. В итоге картинка будет такой:
flowchart TD
subgraph ChatGPT
U[Пользователь
в чате]
W["Виджет App
(Apps SDK)"]
end
subgraph MCP-Клиент
C[ChatGPT MCP client]
end
subgraph OurServer[Наш MCP-сервер]
T1[Tool: suggest_gifts]
R1[Resource: gift_catalog]
P1[Prompt: birthday_template]
end
U --> W
W -- callTool --> C
C <-- JSON-RPC / HTTP --> OurServer
OurServer --> C
C --> W
То есть теперь:
- модель внутри ChatGPT видит наш MCP‑сервер как стандартный набор tools/resources/prompts;
- callTool из виджета логически превращается во внутренний MCP‑вызов;
- наш сервер описывает контракты (схемы, описания) и реализует бизнес‑логику.
К концу этой лекции у вас должен появиться отдельный Node/TypeScript‑проект с MCP‑сервером, который:
- поднимается локально одной командой;
- регистрирует хотя бы один инструмент и один ресурс;
- возвращает осмысленные данные (хоть и с простыми моками);
- структурирован так, чтобы его можно было дальше развивать.
При этом существующий backend через Apps SDK/Next.js мы сейчас не переписываем: он остаётся как есть, а MCP‑сервер поднимаем как отдельный сервис рядом. Позже вы сможете «подцепить» его к ChatGPT App и постепенно перенести туда подарочную логику вместо старых заглушек.
2. Стек: TypeScript + MCP SDK + HTTP‑транспорт
Мы будем писать MCP‑сервер на TypeScript под Node.js. Официальный JS/TS SDK для MCP живёт в пакете @modelcontextprotocol/sdk. Он берёт на себя рутину по JSON‑RPC, валидации и конвертации схем: вы описываете аргументы через Zod‑схемы, а SDK сам переводит их в JSON Schema, понятную модели.
Для транспорта нам нужен HTTP‑вариант: ChatGPT общается с удалёнными MCP‑серверами по сети, а не через stdio/локально. Спецификация MCP описывает стандартный формат «потокового HTTP» — по сути эволюцию старой схемы HTTP+SSE. На практике это один HTTP‑endpoint, который обрабатывает запрос (POST/GET) и при необходимости стримит ответ. В TypeScript‑SDK для MCP обычно уже есть готовый транспорт под такой формат, который можно прикрутить к Express или Hono.
Чтобы не распыляться, мы будем считать, что у нас есть:
- серверный объект McpServer из @modelcontextprotocol/sdk;
- HTTP‑транспорт (например, StreamableHttpServerTransport или аналогичный), который можно подружить с Express.
Точные имена классов могут слегка меняться между версиями SDK, но архитектурно это всегда:
- создаёте объект MCP‑сервера;
- регистрируете на нём tools/resources/prompts;
- подключаете транспорт к HTTP‑приложению.
3. Структура проекта и подготовка
Сделаем отдельную папку под MCP‑сервер. Её удобно держать рядом с фронтенд‑приложением, но как отдельный Node‑проект:
chatgpt-gift-app/
app/ ← Next.js + Apps SDK (виджет)
mcp-server/ ← наш MCP-сервер
Внутри mcp-server:
mcp-server/
src/
server.ts ← точка входа MCP-сервера
gifts.ts ← бизнес-логика подбора подарков
package.json
tsconfig.json
Пример простого gifts.ts мы сделаем чуть позже, сейчас сосредоточимся на server.ts.
Представим, что вы уже инициализировали проект:
mkdir mcp-server
cd mcp-server
npm init -y
npm install typescript ts-node-dev zod express @modelcontextprotocol/sdk
tsconfig.json — самый обычный (esnext modules, target node, strict). Можно взять из любого вашего TS‑проекта.
4. Выносим бизнес‑логику в отдельный модуль
Очень хочется сразу написать server.registerTool(..., async () => {...}) и там же наклепать всю логику. Но лучше с самого начала разделить:
- модуль, который ничего не знает про MCP, JSON‑RPC и прочие ужасы;
- модуль, который знает только про MCP, но мало знает про бизнес-логику.
В src/gifts.ts опишем простую функцию подбора подарков:
// src/gifts.ts
export type GiftIdea = {
id: string;
title: string;
price: number;
occasion: string;
};
export type SuggestGiftsInput = {
age: number;
relationship: "friend" | "partner" | "child" | "coworker";
budget: number;
};
export function suggestGifts(input: SuggestGiftsInput): GiftIdea[] {
// пока просто моки
return [
{
id: "book-1",
title: "Книга по любимому хобби",
price: Math.min(input.budget, 30),
occasion: "generic",
},
{
id: "game-1",
title: "Настольная игра для компании",
price: Math.min(input.budget, 50),
occasion: "party",
},
];
}
Эта функция чистая: на входе параметры, на выходе массив идей. Её можно тестировать юнит‑тестами, переиспользовать в другом месте, и она никак не зависит от MCP. Именно так и рекомендуют делать: серверная обвязка отдельна, бизнес‑функции отдельны.
5. Создаём MCP‑сервер и подключаем HTTP‑транспорт
Теперь точка входа src/server.ts. Схематично нам нужно:
- создать экземпляр MCP‑сервера;
- зарегистрировать на нём инструменты, ресурсы и промпты;
- поднять HTTP‑сервер (например, Express) и прикрутить к нему MCP‑транспорт.
Начнём с заготовки:
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server";
import { StreamableHttpServerTransport } from "@modelcontextprotocol/sdk/transport/streamable-http";
const app = express();
// 1. Создаём MCP-сервер
const mcpServer = new McpServer({
name: "gift-assistant-mcp",
version: "0.1.0",
});
// 2. Здесь позже зарегистрируем tools/resources/prompts
// 3. Настраиваем транспорт поверх HTTP
const transport = new StreamableHttpServerTransport({
path: "/mcp", // единый endpoint MCP
app, // встраиваемся в Express-приложение
});
transport.attach(mcpServer);
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
console.log(`MCP server listening on http://localhost:${PORT}/mcp`);
});
Конкретные имена класса транспорта могут отличаться, но паттерн один: вы создаёте HTTP‑endpoint и подключаете к нему MCP‑сервер как обработчик JSON‑RPC поверх HTTP/стрима.
На этом этапе сервер ещё ничего полезного не делает, но он уже умеет:
- проходить MCP‑handshake;
- отвечать на базовые запросы discovery (список tools/resources/prompts — пока пустой).
Следующий шаг — зарегистрировать первый инструмент.
6. Регистрируем tool suggest_gifts через MCP SDK
Официальный Apps SDK и MCP‑документация показывают один и тот же паттерн регистрации инструмента: метод registerTool, куда вы передаёте имя, дескриптор (заголовок, описание, схему аргументов) и обработчик.
Мы уже описали тип SuggestGiftsInput в gifts.ts. Теперь добавим Zod‑схему, чтобы сервер мог валидировать входные аргументы и автоматически отдать LLM корректный JSON Schema.
// src/server.ts (фрагмент)
import { z } from "zod";
import { suggestGifts } from "./gifts";
const suggestGiftsInputSchema = z.object({
age: z.number().int().min(0).max(120),
relationship: z.enum(["friend", "partner", "child", "coworker"]),
budget: z.number().min(0),
});
Теперь регистрируем инструмент:
// всё ещё в server.ts
mcpServer.registerTool(
"suggest_gifts",
{
title: "Suggest gift ideas",
description:
"Подбирает идеи подарков по возрасту, типу отношений и бюджету.",
// SDK сконвертирует Zod-схему в JSON Schema для модели
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
const ideas = suggestGifts(input);
const text = ideas
.map(
(g) =>
`• ${g.title} — ~${g.price} USD (occasion: ${g.occasion}, id: ${g.id})`
)
.join("\n");
return {
content: [
{
type: "text",
text,
},
],
// structuredContent можно использовать в виджете
structuredContent: {
ideas,
},
};
}
);
Ключевые моменты:
- inputSchema — Zod‑схема. SDK для TS умеет превращать её в JSON Schema и таким образом автоматически описывает инструмент для модели.
- Обработчик принимает объект с input (тип которого вы получаете из схемы). Внутри вы можете вызывать свою бизнес‑функцию.
- В result вы возвращаете content — это текст, который модель увидит как результат, и, при желании, structuredContent с JSON‑структурой, которую потом может потребить ваш виджет.
Если вы в предыдущих модулях уже делали инструмент через Apps SDK, этот код должен выглядеть очень знакомо: паттерн ровно тот же, только теперь он живёт в отдельном MCP‑сервере.
7. Добавляем ресурс gift_catalog для данных
Инструменты — это действия. Иногда хочется ещё предоставлять данные как ресурс, чтобы модель могла их читать, искать по ним или чтобы ваш виджет мог подгружать шаблоны, компоненты и так далее. MCP отдельно описывает концепцию ресурсов с URI, MIME‑типами и содержимым.
Сделаем простой ресурс gift_catalog, который возвращает список доступных подарков. Пока это будут те же моки, но в реале это могла бы быть выгрузка из базы или product feed.
Сначала сам каталог:
// src/gifts.ts (дополнение)
export const giftCatalog: GiftIdea[] = [
{
id: "book-1",
title: "Книга по программированию",
price: 25,
occasion: "learning",
},
{
id: "lego-1",
title: "Набор LEGO",
price: 60,
occasion: "fun",
},
];
Теперь регистрируем ресурс на сервере:
// src/server.ts (фрагмент)
import { giftCatalog } from "./gifts";
mcpServer.registerResource(
"gift_catalog",
{
title: "Gift catalog",
description: "Простой каталог подарков для демо и отладки.",
mimeType: "application/json",
},
async () => {
return {
contents: [
{
uri: "mcp://gift-catalog",
mimeType: "application/json",
text: JSON.stringify(giftCatalog, null, 2),
},
],
};
}
);
Что здесь происходит логически:
- имя ресурса gift_catalog будет видно клиенту при discovery (в MCP‑инспекторе вы потом его увидите в списке ресурсов);
- дескриптор содержит человекочитаемое описание и MIME‑тип;
- обработчик возвращает массив contents с URI и текстом — это стандартный формат ресурса в MCP.
Позже вы сможете:
- читать этот ресурс с клиента (например, агент или инспектор);
- использовать его в качестве шаблонов/данных для UI;
- проводить эксперименты: как модель использует готовый каталог, чтобы объяснять пользователю варианты.
8. Регистрируем простой prompt
Третья сущность MCP — промпты, заранее заготовленные подсказки. Они позволяют не повторять длинные системные или пользовательские промпты, а хранить их на сервере с именами.
Сделаем мини‑пример: промпт birthday_gift, который можно будет вызывать как «предзаполненный шаблон разговора о подарке на день рождения».
// src/server.ts (фрагмент)
mcpServer.registerPrompt("birthday_gift", {
title: "Birthday gift helper",
description: "Шаблон запроса для подбора подарка на день рождения.",
messages: [
{
role: "system",
content:
"Ты ассистент по поиску подарков. Задавай уточняющие вопросы и предлагай несколько вариантов.",
},
{
role: "user",
content:
"Мне нужен подарок на день рождения. Спроси нужные уточнения и помоги выбрать.",
},
],
});
Под капотом MCP позволит клиентам:
- получить список промптов (в инспекторе вы увидите birthday_gift);
- запросить его содержимое и использовать в качестве базовой подсказки для модели.
Отдельно, в модуле про system‑prompt и инструкции, мы подробно разбираем, как такие промпты сочетаются с глобальными инструкциями приложения. Здесь же нам важно просто «увидеть» их как часть MCP‑сервера.
9. Как всё это работает в рантайме
Соберём картинку целиком.
Когда клиент (например, MCP Inspector или ChatGPT) подключается к нашему HTTP‑endpoint /mcp:
- происходит handshake: клиент и сервер обмениваются информацией о поддерживаемых возможностях (tools/resources/prompts и т.д.);
- клиент вызывает методы discovery: получает список инструментов, ресурсов, промптов вместе с их описаниями и схемами;
- когда модель решает вызвать инструмент, она формирует JSON‑RPC запрос с методом вроде tools/call или аналогичным — SDK на стороне сервера превращает это во внутренний вызов registerTool‑обработчика;
- обработчик выполняет бизнес‑логику (у нас это suggestGifts или выдача giftCatalog) и возвращает результат в стандартизованном формате;
- SDK сериализует ответ обратно в JSON‑RPC и отправляет клиенту через тот же HTTP/стрим‑транспорт.
Все детали JSON‑RPC, формирования id, маршрутизации методов и так далее остаются внутри @modelcontextprotocol/sdk. Для вас интерфейс очень похож на Apps SDK: вы работаете с registerTool/registerResource/registerPrompt и обработчиками, не задумываясь о протоколе.
10. Локальный запуск и первый простой тест
Предположим, вы добавили всё, что выше. Осталось запустить.
В package.json можно добавить скрипт:
{
"scripts": {
"dev": "ts-node-dev src/server.ts"
}
}
Запускаем:
npm run dev
В консоли должно появиться что‑то вроде:
MCP server listening on http://localhost:4000/mcp
Полноценную инспекцию и ручные вызовы инструментов мы будем делать в следующей лекции через MCP Inspector / MCP Jam. Но даже сейчас можно сделать супер‑простой smoke‑тест через curl:
curl -X POST http://localhost:4000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Этот curl — чисто факультативный smoke‑тест для тех, кто любит смотреть на «сырые» JSON‑ответы. В реальной разработке вы почти всегда общаетесь с MCP‑сервером через SDK, а не руками собираете JSON‑RPC‑запросы.
Точное имя метода зависит от версии протокола и SDK, но идея в том, что вы получите JSON‑список, где среди tools будет видно suggest_gifts. Если метод не совпадает — не страшно: задача лекции не в том, чтобы наизусть помнить все имена, а в том, чтобы вы не боялись смотреть на JSON‑ответы и понимали их структуру, благодаря предыдущим лекциям.
11. Связь с нашим ChatGPT App и дальнейшее развитие
Пока MCP‑сервер живёт сам по себе. В следующих модулях вы:
- подключите его к MCP Inspector и научитесь отлаживать tools/resources/prompts по отдельности, не трогая ChatGPT;
- настроите ChatGPT App так, чтобы он видел этот MCP‑сервер как источник инструментов;
- перенесёте часть логики, которая раньше была реализована внутри Apps SDK (например, через встроенные tools), в MCP‑слой;
- добавите авторизацию, логирование, потоковые сценарии — уже над готовым каркасом.
Сейчас важно, что:
- у вас есть отдельный сервис, отвечающий за «умения» и «данные» приложения;
- этот сервис говорит с клиентами через стандарт MCP, а не через кастомный REST;
- вы уже умеете руками регистрировать инструменты, ресурсы и промпты, не боясь протокола.
12. Немного о структуре кода и best practices
Даже на таком маленьком примере можно заложить хорошие привычки.
Во‑первых, держите конфигурацию сервера отдельно. Всё, что касается имени, версии, логирования, настроек транспорта (порт, путь /mcp), легко вынести в маленький модуль config.ts. Потом, когда будете деплоить на Vercel или за MCP‑gateway, придётся добавлять env‑переменные, и вы себя поблагодарите.
Во‑вторых, старайтесь, чтобы методы registerTool/registerResource/registerPrompt оставались максимально «тонкими». Описание схем, текстов и бизнес‑логика — штуки, которые хорошо смотрятся в отдельных файлах:
- gifts.ts — функции выбора подарков;
- catalog.ts — работа с каталогом товаров;
- prompts.ts — набор промптов.
Сам server.ts тогда превращается в нечто вроде «провайдера MCP», который просто склеивает всё воедино.
В‑третьих, помните, что MCP‑сервер по своей природе реактивен: он ожидает подключения клиентов и их запросов. Это значит, что любые блокирующие или чрезмерно долгие операции внутри инструментов будут напрямую влиять на UX в ChatGPT. В следующих модулях мы поговорим и про таймауты, и про асинхронные операции, и про потоковые ответы, но уже сейчас стоит думать, какие операции можно выделить в фон, а какие должны отвечать быстро.
Insight: ChatGPT поддерживает только часть MCP
Важно понимать: ChatGPT Apps используют MCP как транспорт и формат, но не являются полноценным MCP-клиентом. Если читать только протокол, легко построить неверные ожидания о том, как всё будет работать в рантайме.
Что обещает «чистый» MCP:
- ресурсы (resources) могут читаться динамически, по запросу клиента, а не один раз навсегда;
- сервер может отправлять resourceChanged/toolChanged-нотификации и тем самым «пропихивать» обновления без перезапуска клиента;
- можно строить достаточно гибкую систему, где набор tools/resources/prompts управляется конфигами или внешним состоянием.
В контексте ChatGPT Apps это не так. Для приложения картинка гораздо более статична:
- при регистрации App ChatGPT один раз считывает описание всех tools и resources;
- затем эта конфигурация фактически кэшируется как часть версии приложения;
- динамические обновления через MCP-нотификации не поддерживаются — платформа их просто игнорирует.
13. Типичные ошибки при написании первого MCP‑сервера
Ошибка №1: Свалить всю бизнес‑логику прямо в registerTool.
Соблазн «быстренько написать всё в обработчике инструмента» огромен, особенно в учебном примере. Но потом это превращается в нечитаемый комбайн, где вперемешку валидация, работа с БД и форматирование ответа. Лучше сразу вынести бизнес‑функции (suggestGifts, работу с каталогом) в отдельные модули, а в обработчике делать только «склейку».
Ошибка №2: Жёстко привязаться к конкретным именам JSON‑методов MCP.
Иногда студенты начинают прописывать if (method === "tools/list") и ручками парсить JSON. Так делать не нужно: это работа SDK. MCP‑спека и имена методов могут эволюционировать, а SDK берёт эту заботу на себя. Используйте registerTool, registerResource, registerPrompt и дайте библиотеке решать, как это выглядит в JSON‑RPC.
Ошибка №3: Не думать о транспорте и пытаться кормить ChatGPT stdio‑сервером.
Stdio‑транспорт идеален для локальных клиентов вроде десктопных окружений, где клиент может запускать сервер как подпроцесс. Но ChatGPT общается по HTTPS, и ему нужен HTTP/стрим‑endpoint. Попытка «как‑нибудь прокинуть stdio» через туннель заканчивается болью. Для ChatGPT App сразу делайте HTTP‑транспорт (Streamable HTTP).
Ошибка №4: Игнорировать MIME‑типы и структуру ресурсов.
У ресурсов важно не только содержимое, но и тип (mimeType) и URI. Если везде писать text/plain и бездумно кидать JSON‑строки, клиентам (и инспекторам) будет труднее понимать, что это за данные. Старайтесь указывать корректные MIME‑типы (application/json, text/html для шаблонов UI и т.п.) и стабильные URI.
Ошибка №5: Использовать MCP‑сервер как «рандомный HTTP‑API».
Иногда возникает искушение: «Ну раз у меня уже есть Express, я повешу ещё /api/whatever и буду туда стучаться напрямую». Смешивать MCP‑endpoint с произвольным REST лучше не стоит: это усложняет конфигурацию, роутинг и безопасность. Проще иметь чёткий контракт: /mcp для MCP, отдельные пути для других нужд, или вообще другой сервис. В продакшене это особенно важно для конфигурирования gateways и авторизации. То есть не превращайте MCP‑сервер в «рандомный HTTP‑API» — набор случайных HTTP‑ручек, не связанных с MCP‑контрактом.
Ошибка №6: Не логировать входящие и исходящие MCP‑сообщения.
Без логов MCP‑сервер превращается в чёрный ящик: «что‑то не работает, но я не знаю, что». Уже на первом сервере имеет смысл хотя бы в stderr писать компактные структурированные логи: метод инструмента, статус, время выполнения. Главное — не логировать чувствительные данные и токены, это мы дальше отдельно обсудим, когда дойдём до безопасности.
Ошибка №7: Пытаться отлаживать всё сразу через ChatGPT, не имея инспектора.
Частая картина: студент пишет MCP‑сервер, тут же подключает его к ChatGPT App, и всё «непонятно ломается». При этом инспектор даже ни разу не запускался. В результате сложно понять, проблема в протоколе, в сервере, в Apps SDK или в поведении модели. Правильный путь — сначала убедиться, что MCP‑сервер корректно работает в изоляции (через MCP Jam / Inspector), а уже потом подключать его к приложению.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ