Данила (Dayfing)
Назад к публикациям
2 158 слов13 мин

MCP-сервер на TypeScript: Streamable HTTP, OAuth и минимальные права

В этом руководстве мы соберём небольшой удалённый 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.

Источники

Ещё публикации