Цей посібник створює невеликий віддалений 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 отримує factory і створює новий McpServer для кожного HTTP-запиту. toNodeHandler адаптує його web-standard інтерфейс fetch до об’єктів запиту й відповіді Node. Адаптер Express дає розбір JSON, захист host і origin для налаштованого хоста та OAuth middleware.
MCP-сервер є OAuth resource server. Він перевіряє токени, видані authorization server, але сам токени не видає. Наведений verifier використовує 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 є воротами на рівні endpoint. Дві перевірки інструментів дають дрібніше розділення. Клієнт, який має лише mcp, може виявити endpoint, але при виклику захищеної операції отримує звичайний результат інструмента з isError: true. Відмова залишається видимою для моделі. Якщо весь endpoint потребує scope, додайте його до requiredScopes. Middleware поверне 403 з insufficient_scope.
Metadata router публікує Protected Resource Metadata RFC 9728 і віддзеркалює metadata authorization server RFC 8414. Клієнт може перейти за URL resource_metadata із challenge WWW-Authenticate, дізнатися issuer та endpoints, отримати токен і повторити запит. TypeScript SDK не реалізує ваш identity provider. Для нового сервісу використовуйте підтримуваний identity provider або OAuth server. Helpers authorization server v1 у @modelcontextprotocol/server-legacy/auth заморожені для міграції, тому не є рекомендованим вибором для нового коду.
Правила авторизації 2026-07-28 посилюють і клієнтську частину. Authorization server має повертати iss у відповідях авторизації. Клієнт порівнює його з issuer, збереженим під час discovery, перш ніж надіслати code на token endpoint. Client credentials і токени треба розділяти за issuer. Client ID Metadata Documents є кращими за Dynamic Client Registration, яка залишена для сумісності. Це не дає mix-up authorization server перетворити code чи token одного сервера на credential для іншого.
Явно використовуйте stateless
Factory створює сервер на кожен запит, тому не зберігайте автентифікованого caller, фазу workflow або рішення про дозвіл в instance, очікуючи побачити його в наступному 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. Стан підписаний, але не зашифрований, а SDK вважає echoed state недовіреним до перевірки hook. Прив’яжіть його до principal та операції, задайте expiry і не кладіть секрети в payload. Усі replicas, які можуть отримати retry, повинні мати спільний HMAC-ключ.
Валідуйте, обмежуйте й розгортайте
Валідація 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 або політику 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(), щоб поточні сучасні обміни зупинилися до завершення процесу.
Тестуйте дріт, а не тільки функцію
SDK v2 документує in-process шлях, який викликає createMcpHandler через StreamableHTTPClientTransport із власною fetch-функцією. Використовуйте справжній request/response шлях, щоб охопити version negotiation, обов’язкові заголовки, JSON і SSE, передавання authentication, помилки schema, cancellation та retry input_required. Додайте окремі тести для 401 без token, 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 і переконайтеся, що видимість та 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 та числовий expiration claim. Bearer gate SDK відхиляє AuthInfo без expiresAt. 403 insufficient_scope означає endpoint-level scope challenge і може запустити step-up flow клієнта. Відмова інструмента з isError: true є результатом застосунку і не авторизує ширший token автоматично.
Якщо клієнт зависає, перевірте URL, buffering SSE на reverse proxy, idle timeout і відповідь із тим самим JSON-RPC ID. Якщо modern client бачить legacy server, перевірте versionNegotiation зі значенням auto або pin, відповідність версій SDK і те, що gateway не видалив _meta чи обов’язкові заголовки. Якщо 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