В этом руководстве мы соберём небольшой удалённый MCP-сервер для сервиса заметок. Пример использует стабильную ветку v2 TypeScript SDK, которая реализует ревизию MCP 2026-07-28. Сервер принимает современные запросы Streamable HTTP, проверяет OAuth access token как сервер ресурсов, публикует инструменты чтения и удаления, проверяет аргументы через Zod и выполняет проверку права рядом с защищаемой операцией. Код представляет рабочий пример, но эта статья не утверждает, что он запускался в вашей среде.
Версия протокола здесь существенна. Пакеты v2 разделены на @modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core и адаптеры среды выполнения. Не переносите в новый сервис старые примеры с импортом @modelcontextprotocol/sdk/server/mcp.js. О том, что изменилось в проводе и пакетах, рассказывает руководство по миграции MCP 2026-07-28.
Начните с современной модели провода
Streamable HTTP использует одну MCP-точку, обычно /mcp, а каждый JSON-RPC-запрос клиента отправляется отдельным HTTP POST. Ответом становится один JSON-объект или поток SSE, относящийся к этому запросу. Уведомление подтверждается статусом 202 без тела. Клиент указывает в Accept оба типа, application/json и text/event-stream, а JSON отправляет с Content-Type: application/json.
Ревизия 2026-07-28 убирает старый GET-поток, идентификаторы протокольной сессии и возобновление по истории Last-Event-ID. Она также убирает независимые JSON-RPC-запросы от сервера к клиенту. Если инструменту нужно подтверждение или ещё одно значение, он возвращает результат input_required. Клиент отвечает на встроенный запрос и повторяет исходный вызов. Долгоживущие уведомления об изменении списков идут через поток ответа subscriptions/listen, а не через общий GET-канал.
В каждый современный POST входит MCP-Protocol-Version, и его значение должно совпадать с _meta.io.modelcontextprotocol/protocolVersion в JSON-теле. Mcp-Method повторяет JSON-RPC-метод для каждого запроса. Mcp-Name повторяет params.name или params.uri для запросов tools/call, resources/read и prompts/get. Шлюз может использовать эти заголовки для маршрутизации, но приложение всё равно обязано отклонить несовпадение заголовка и тела. Источником истины остаётся тело.
Минимальный современный вызов выглядит так:
POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: notes.search
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"notes.search","arguments":{"query":"gateway"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"notes-cli","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
Установите SDK v2
Создайте ESM-проект Node.js с Node.js 20 или новее, TypeScript 6 и пакетами SDK. Зафиксируйте версии в lockfile приложения. На момент проверки стабильные серверный пакет, адаптер Node и адаптер Express имеют версию 2.0.0. Для схем Zod серверному пакету нужен Zod 4.
npm install @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] express@5 zod@4
npm install --save-dev typescript@6 @types/node @types/express tsx
В TypeScript 6 явно добавьте типы Node.js, если ваша конфигурация не включает их сама:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"]
}
}
Постройте сервер и шлюз ресурсного сервера
createMcpHandler получает фабрику и создаёт свежий McpServer для каждого HTTP-запроса. toNodeHandler адаптирует его web-standard интерфейс fetch к объектам запроса и ответа Node. Адаптер Express предоставляет разбор JSON, защиты host и origin для явно настроенного адреса и OAuth middleware.
MCP-сервер является OAuth resource server. Он проверяет токены, выпущенные authorization server, но сам токены не выдаёт. Верификатор ниже использует introspection по RFC 7662. Он намеренно проверяет active, sub и exp, разбирает разделённое пробелами поле scope и возвращает AuthInfo SDK. Переходите на локальную проверку JWT только после реализации и тестирования issuer, audience, подписей, ключей, политики времени и ротации ключей.
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth,
type OAuthTokenVerifier
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
createMcpHandler,
McpServer,
OAuthError,
OAuthErrorCode,
type AuthInfo,
type OAuthMetadata
} from "@modelcontextprotocol/server";
import type { Request, Response } from "express";
import * as z from "zod/v4";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const host = process.env.HOST ?? "127.0.0.1";
const mcpUrl = new URL(required("MCP_URL"));
const scopes = ["mcp", "notes:read", "notes:write"];
const oauthMetadata: OAuthMetadata = {
issuer: required("OAUTH_ISSUER"),
authorization_endpoint: required("OAUTH_AUTHORIZATION_ENDPOINT"),
token_endpoint: required("OAUTH_TOKEN_ENDPOINT"),
response_types_supported: ["code"],
scopes_supported: scopes,
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true
};
const notes = [
{ id: "1", text: "Rotate the signing key after the release." },
{ id: "2", text: "Review the MCP gateway rate limit." }
];
async function verifyAccessToken(token: string): Promise<AuthInfo> {
const credentials = Buffer.from(`${required("OAUTH_CLIENT_ID")}:${required("OAUTH_CLIENT_SECRET")}`).toString("base64");
const response = await fetch(required("OAUTH_INTROSPECTION_URL"), {
method: "POST",
headers: {
authorization: `Basic ${credentials}`,
"content-type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({ token }).toString()
});
if (!response.ok) throw new OAuthError(OAuthErrorCode.InvalidToken, "Token introspection failed");
const payload = await response.json() as {
active?: unknown;
sub?: unknown;
client_id?: unknown;
scope?: unknown;
exp?: unknown;
};
if (payload.active !== true || typeof payload.sub !== "string" || typeof payload.exp !== "number") {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is inactive or incomplete");
}
const tokenScopes = typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [];
return {
token,
clientId: typeof payload.client_id === "string" ? payload.client_id : payload.sub,
scopes: tokenScopes,
expiresAt: payload.exp
};
}
const verifier: OAuthTokenVerifier = { verifyAccessToken };
function buildServer(): McpServer {
const server = new McpServer({ name: "notes", version: "1.0.0" });
server.registerTool(
"notes.search",
{
title: "Search notes",
description: "Find notes containing a phrase.",
inputSchema: z.object({
query: z.string().trim().min(1).max(200),
limit: z.number().int().min(1).max(20).default(10)
})
},
async ({ query, limit }, ctx) => {
if (!ctx.http?.authInfo?.scopes.includes("notes:read")) {
return { content: [{ type: "text", text: "insufficient_scope: notes:read is required" }], isError: true };
}
const needle = query.toLocaleLowerCase();
const matches = notes.filter(note => note.text.toLocaleLowerCase().includes(needle)).slice(0, limit);
return { content: [{ type: "text", text: JSON.stringify(matches) }] };
}
);
server.registerTool(
"notes.delete",
{
title: "Delete a note",
description: "Delete one note by id.",
inputSchema: z.object({ id: z.string().regex(/^\d+$/) })
},
async ({ id }, ctx) => {
if (!ctx.http?.authInfo?.scopes.includes("notes:write")) {
return { content: [{ type: "text", text: "insufficient_scope: notes:write is required" }], isError: true };
}
const index = notes.findIndex(note => note.id === id);
if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
const [removed] = notes.splice(index, 1);
return { content: [{ type: "text", text: `deleted ${removed.id}` }] };
}
);
return server;
}
const handler = createMcpHandler(buildServer);
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpUrl);
const app = createMcpExpressApp({ host, allowedHosts: [mcpUrl.hostname], jsonLimit: "64kb" });
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpUrl, scopesSupported: scopes, resourceName: "Notes MCP" }));
const auth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], resourceMetadataUrl });
const node = toNodeHandler(handler);
app.all(mcpUrl.pathname, auth, (req: Request, res: Response) => {
void node(req, res, req.body);
});
const port = mcpUrl.port ? Number(mcpUrl.port) : Number(process.env.PORT ?? 3000);
app.listen(port, host);
Scope mcp является gate на уровне точки. Две проверки инструментов дают более мелкое разделение. Клиент только с mcp может обнаружить точку, но при вызове защищённой операции получает обычный результат инструмента с isError: true. Отказ остаётся видимым модели. Если весь endpoint требует scope, укажите его в requiredScopes. Middleware тогда вернёт 403 с insufficient_scope.
Metadata router публикует Protected Resource Metadata по RFC 9728 и отражает переданные metadata authorization server по RFC 8414. Клиент может перейти по resource_metadata из WWW-Authenticate, узнать issuer и endpoints, получить токен и повторить запрос. TypeScript SDK не реализует вашего identity provider. Для нового сервиса используйте поддерживаемый identity provider или OAuth server. Старые helpers authorization server в @modelcontextprotocol/server-legacy/auth заморожены для миграции, поэтому не являются предпочтительным выбором для нового кода.
Правила авторизации 2026-07-28 усиливают и клиентскую сторону. Authorization server должен возвращать iss в authorization response. Клиент проверяет его с issuer, сохранённым при discovery, до отправки code на token endpoint. Client credentials и токены нужно разделять по issuer. Client ID Metadata Documents предпочтительнее Dynamic Client Registration, которая оставлена для совместимости. Так authorization-server mix-up не превращает code или token одного сервера в credential другого.
Сделайте stateless режим явным
Фабрика создаёт сервер для каждого запроса, поэтому нельзя сохранять в экземпляре caller, фазу workflow или решение о разрешении и ожидать их на следующем POST. Современные запросы могут попасть на любую replica за обычным round-robin load balancer. Постоянное состояние предметной области храните в базе или очереди, а в аргументе инструмента передавайте явный короткоживущий handle с валидацией.
Для подтверждения верните input_required и защитите его состояние. SDK экспортирует inputRequired, acceptedContent и createRequestStateCodec:
import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
function buildServerWithConfirmation(): McpServer {
const key = process.env.REQUEST_STATE_KEY;
if (!key) throw new Error("Missing REQUEST_STATE_KEY");
type DeleteState = { noteId: string; step: "confirm" };
const stateCodec = createRequestStateCodec<DeleteState>({
key,
ttlSeconds: 300,
bind: ctx => `${ctx.mcpReq.method}\u0000${ctx.http?.authInfo?.clientId ?? ""}`
});
const server = new McpServer(
{ name: "notes-confirmation", version: "1.0.0" },
{ requestState: { verify: stateCodec.verify } }
);
const notes = [{ id: "1", text: "Rotate the signing key after the release." }];
const confirmationSchema = z.object({ confirm: z.boolean() });
server.registerTool(
"notes.delete",
{ inputSchema: z.object({ id: z.string().regex(/^\d+$/) }) },
async ({ id }, ctx) => {
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, "confirm", confirmationSchema);
if (!confirmed) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: "Delete this note?",
requestedSchema: confirmationSchema
})
},
requestState: await stateCodec.mint({ noteId: id, step: "confirm" }, ctx)
});
}
if (!confirmed.confirm) return { content: [{ type: "text", text: "deletion declined" }], isError: true };
const index = notes.findIndex(note => note.id === id);
if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
notes.splice(index, 1);
return { content: [{ type: "text", text: `deleted ${id}` }] };
}
);
return server;
}
Подключите stateCodec.verify к опции requestState.verify, переданной в McpServer. Состояние подписано, но не зашифровано. До проверки hook SDK считает echoed state недоверенным. Свяжите его с principal и операцией, задайте expiry и не помещайте секреты в payload. Общий HMAC-ключ нужен, если retry может принять другая replica.
Проверьте, ограничьте и разверните
Проверка Zod является первой границей приложения, но не всей моделью безопасности. Ограничивайте строки, массивы, числа, ID, URL, пути и размеры результатов. Проверяйте данные ещё раз на границе downstream API, потому что schema подтверждает форму, но не право и безопасность побочного эффекта. Не позволяйте модели выбирать произвольные HTTP-методы, hosts, filesystem paths, SQL-фрагменты или shell arguments. Используйте allowlist и параметризованные API.
Rate limiting должен использовать проверенный clientId или tenant как основной ключ, а также отдельные лимиты для неаутентифицированных ошибок, discovery, дорогих инструментов и числа параллельных операций. На edge или gateway возвращайте 429 с Retry-After. Счётчик внутри процесса подходит для одной разработки, но не является общим лимитом кластера, а неограниченная map может создать давление на память. Для нескольких replicas используйте общий limiter, например Redis или policy gateway. В метриках сохраняйте method и tool name из проверенных заголовков, но никогда не записывайте bearer token.
Завершайте TLS на публичной точке или используйте end-to-end TLS, сохраняйте поток SSE и отключайте buffering proxy через X-Accel-Buffering: no, если это поддерживает proxy. Явно настройте лимиты тела, заголовков, простоя и upstream. Локальный сервер привязывайте к 127.0.0.1, а не к 0.0.0.0, если только allowlist и authentication не защищают его. Express factory включает host и origin protections только для настроенного localhost класса. Для public bind явно задайте allowedHosts и allowedOrigins.
Современный протокол не требует sticky sessions. Поток subscriptions/listen отличается: если уведомления должны работать между replicas, передайте общий ServerEventBus поверх pub/sub. Пулы базы и кэши держите на уровне модуля, но решения для конкретного caller оставляйте в request context. При graceful shutdown вызывайте handler.close(), чтобы in-flight обмены завершились до выхода процесса.
Тестируйте wire, а не только функцию
SDK v2 документирует in-process путь, который вызывает createMcpHandler через StreamableHTTPClientTransport с собственной fetch-функцией. Используйте настоящий путь request/response, чтобы тесты покрывали version negotiation, обязательные заголовки, JSON и SSE, передачу authentication, ошибки schema, cancellation и retry input_required. Добавьте отдельные тесты для 401 без токена, 401 inactive token, 403 без endpoint scope и in-band isError при отсутствии scope инструмента.
Для теста modern wire используйте mode: { pin: "2026-07-28" }. В compatibility test используйте mode: "auto" и проверяйте legacy fallback на намеренно старом fixture. Отправьте два запроса от разных callers и убедитесь, что visibility и authorization инструментов не переходят между ними. В multi-node тесте отправьте retry в другой процесс и проверьте, что общий state codec и durable store сохраняют workflow.
Диагностируйте с первого слоя ошибки
HTTP 400 с HeaderMismatch означает, что protocol header и claim в _meta расходятся или отсутствует обязательный routing header. Исправьте client или gateway и не переходите молча на старый протокол, если тело является распознанной современной ошибкой. HTTP 415 означает, что media type POST не равен application/json. Строка вроде text/plain; a=application/json недействительна. 404 для GET ожидаем на современной точке. 404 для POST может означать неизвестный method или маршрут, который не дошёл до MCP handler.
При 401 должен присутствовать challenge WWW-Authenticate. Проверьте metadata URL, issuer токена, audience или resource, подпись либо introspection и числовой expiry claim. Bearer gate SDK отклоняет AuthInfo без expiresAt. 403 insufficient_scope означает endpoint-level scope challenge и может запустить step-up flow клиента. Отказ инструмента с isError: true является результатом приложения и сам по себе не выдаёт более широкий токен.
Если клиент зависает, проверьте URL, buffering SSE на reverse proxy, idle timeout и response для того же JSON-RPC ID. Если modern client видит legacy server, проверьте versionNegotiation со значением auto или pin, совпадение версий SDK и отсутствие удаления _meta или обязательных заголовков на gateway. Если confirmation повторяется бесконечно, проверьте ключ embedded response, валидируйте inputResponses, ограничьте число rounds и изучите фазу подписанного state. За матрицей совместимости обратитесь к руководству по миграции MCP 2026-07-28. Об угрозах injection, tool poisoning и authorization вокруг MCP читайте в материале prompt injection и безопасность MCP. О границах сервисов, очередях и изоляции отказов рассказывает production AI agent architecture.
Источники
- Спецификация MCP Streamable HTTP, 2026-07-28
- Спецификация авторизации MCP, 2026-07-28
- Справочник TypeScript SDK v2
- Справочник API
createMcpHandler - Справочник API
requireBearerAuth - Руководство TypeScript SDK для 2026-07-28
- RFC 7662 OAuth 2.0 Token Introspection
- RFC 9207: идентификация issuer authorization server
- RFC 9728: OAuth 2.0 Protected Resource Metadata