Гэта кіраўніцтва стварае невялікі аддалены 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. Gateway можа выкарыстоўваць гэтыя загалоўкі для маршрутызацыі, але прыкладанне ўсё роўна павінна адхіляць разыходжанне загалоўка і цела. Цела застаецца крыніцай ісціны.
Мінімальны сучасны выклік выглядае так:
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 лічыць стан, вернуты кліентам, ненадзейным да праверкі hook. Прывяжыце яго да principal і аперацыі, задайце тэрмін дзеяння і не кладзіце сакрэты ў 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 чакаецца на сучасным endpoint. 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