Чому MCP важливий
Model Context Protocol був представлений Anthropic наприкінці 2024 року і відтоді став найближчим до стандарту для підключення LLM-застосунків до інструментів, джерел даних та зовнішніх систем. У 2026 році його підтримують Claude Desktop, Claude Code, Cursor, Zed, недавні клієнти OpenAI та довгий хвіст агентів. Якщо ви підтримуєте внутрішній API, який AI-асистенти мають безпечно викликати — ticketing систему, сховище runbook'ів, метрик-бекенд — експонувати його через MCP це шлях найменшого тертя.
Ця стаття — це production-grade версія MCP-сервера. Не hello world. Ми охоплюємо автентифікацію, авторизацію, rate limiting, observability та розгортання. Приклади використовують офіційний TypeScript SDK.
П'ять концепцій, які вам потрібні
MCP визначає невеликий словник. Вивчіть ці п'ять — і ви розумієте 90% специфікації.
- Server. Процес, що експонує можливості.
- Client. LLM-застосунок, що підключається до серверів.
- Tools. Викликувані функції з типізованими аргументами, які модель може інвокувати.
- Resources. Read-only джерела даних (файли, записи, сторінки), які модель може включити в контекст.
- Prompts. Параметризовані prompt templates, які сервер експонує клієнту.
Більшість продакшн MCP-серверів насправді про tools та resources. Prompts корисні, але другорядні.
Транспорт: stdio або HTTP
MCP підтримує кілька транспортів. Для локальної розробки stdio — очевидний вибір: клієнт спавнить сервер як підпроцес, і вони говорять через stdin/stdout. Для продакшну ви хочете HTTP з Server-Sent Events (SSE), це network-friendly транспорт, який специфікація стандартизувала у 2025 році. Він підтримує кількох клієнтів, TLS, auth-хедери та load balancers.
Якщо ваш сервер існує, щоб його споживали агенти, що працюють на ноутбуках інженерів, відправляйте обидва транспорти. Якщо він існує, щоб його споживала продакшн-платформа агентів, відправляйте лише HTTP.
Розкладка проєкту
mcp-ticketing/
├── src/
│ ├── index.ts # entrypoint
│ ├── server.ts # server setup
│ ├── auth.ts # auth middleware
│ ├── tools/
│ │ ├── search.ts
│ │ ├── create_ticket.ts
│ │ └── close_ticket.ts
│ ├── resources/
│ │ └── ticket.ts
│ ├── ratelimit.ts
│ └── telemetry.ts
├── Dockerfile
├── docker-compose.yaml
├── k8s/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── ingress.yaml
├── package.json
└── tsconfig.json
Мінімальний сервер з офіційним SDK
Офіційний пакет @modelcontextprotocol/sdk надає плумінг протоколу. Ви реалізуєте tools та resources; SDK обробляє решту.
// src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { searchTickets, createTicket, closeTicket } from "./tools/index.js";
import { readTicketResource } from "./resources/ticket.js";
export function buildServer(): McpServer {
const server = new McpServer(
{
name: "ticketing",
version: "1.2.0",
},
{
capabilities: {
tools: {},
resources: { subscribe: false, listChanged: true },
},
},
);
server.registerTool(
"search_tickets",
{
title: "Search tickets",
description: "Search the ticketing system by free text and optional status",
inputSchema: {
query: z.string().min(1).max(500),
status: z.enum(["open", "closed", "pending"]).optional(),
limit: z.number().int().min(1).max(50).default(10),
},
},
async (args, extra) => {
const results = await searchTickets(args, extra.sessionId);
return {
content: [
{ type: "text", text: JSON.stringify(results, null, 2) },
],
};
},
);
server.registerTool(
"create_ticket",
{
title: "Create ticket",
description: "Create a new ticket. Requires write permission.",
inputSchema: {
title: z.string().min(1).max(200),
body: z.string().min(1).max(10_000),
priority: z.enum(["low", "medium", "high"]).default("medium"),
},
},
async (args, extra) => {
const ticket = await createTicket(args, extra.sessionId);
return {
content: [{ type: "text", text: `Created ticket ${ticket.id}` }],
};
},
);
server.registerTool(
"close_ticket",
{
title: "Close ticket",
description: "Close an existing ticket by ID.",
inputSchema: {
id: z.string().regex(/^T-\d+$/),
resolution: z.string().max(1000),
},
},
async (args, extra) => {
await closeTicket(args, extra.sessionId);
return { content: [{ type: "text", text: `Closed ${args.id}` }] };
},
);
server.registerResource(
"ticket",
"ticket://{id}",
{
title: "Ticket",
description: "Read a ticket by ID",
mimeType: "application/json",
},
async (uri, { id }) => {
const ticket = await readTicketResource(String(id));
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(ticket),
},
],
};
},
);
return server;
}
Зверніть увагу на extra.sessionId — SDK експонує per-request context, який ви можете використати, щоб пронести автентифіковану ідентичність користувача у ваші tool handlers.
Автентифікація: OAuth, а не API-ключі
Специфікація MCP рекомендує OAuth 2.1 для HTTP-транспортів. На практиці у вас три варіанти:
- OAuth 2.1 з PKCE — правильно, гнучко, найбільше роботи.
- OIDC з вашого існуючого IdP — добре, якщо ваші клієнти вже мають user identity.
- Короткоживучі bearer tokens, видані довіреним control plane — найпростіше для machine-to-machine.
Для інструменту, що представляє автентифіковані дії користувача (створення тикетів від чийогось імені), OAuth зі згодою користувача — правильна модель. Для інструменту, що скоупнутий на єдину сервісну ідентичність, підписані bearer-токени підходять.
Мінімальне middleware верифікації JWT з використанням Hono:
// src/auth.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
import type { Context, Next } from "hono";
const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URL!));
export async function requireAuth(c: Context, next: Next) {
const authHeader = c.req.header("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return c.json({ error: "missing bearer token" }, 401);
}
try {
const { payload } = await jwtVerify(authHeader.slice(7), JWKS, {
issuer: process.env.OIDC_ISSUER,
audience: "mcp-ticketing",
});
c.set("principal", {
sub: payload.sub as string,
scopes: (payload.scope as string | undefined)?.split(" ") ?? [],
});
} catch {
return c.json({ error: "invalid token" }, 401);
}
await next();
}
export function requireScope(scope: string) {
return async (c: Context, next: Next) => {
const principal = c.get("principal") as { scopes: string[] } | undefined;
if (!principal?.scopes.includes(scope)) {
return c.json({ error: `missing scope: ${scope}` }, 403);
}
await next();
};
}
У вашому tool handler перевіряйте scope перед виконанням дії:
// src/tools/create_ticket.ts
import { principalForSession } from "../session.js";
export async function createTicket(
args: { title: string; body: string; priority: string },
sessionId: string,
) {
const principal = principalForSession(sessionId);
if (!principal.scopes.includes("tickets:write")) {
throw new Error("forbidden: tickets:write required");
}
// ...call the real backend
return { id: "T-12345" };
}
Rate Limiting per client
Ви не хочете, щоб один клієнт висушив ваш бекенд. Per-client rate limits живуть у Redis.
// src/ratelimit.ts
import { RateLimiterRedis } from "rate-limiter-flexible";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
const limiters = {
search: new RateLimiterRedis({
storeClient: redis,
keyPrefix: "mcp:search",
points: 120,
duration: 60,
}),
write: new RateLimiterRedis({
storeClient: redis,
keyPrefix: "mcp:write",
points: 20,
duration: 60,
}),
};
export async function enforce(kind: "search" | "write", clientId: string) {
await limiters[kind].consume(clientId);
}
Застосовуйте їх на початку кожного tool handler. 429 пропагуються назад до MCP-клієнта через SDK.
Observability з OpenTelemetry
Кожен tool call має породжувати trace. MCP SDK не інструментує за вас — ви додаєте spans усередині ваших handlers.
// src/telemetry.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { trace, SpanStatusCode } from "@opentelemetry/api";
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: "mcp-ticketing",
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}),
});
sdk.start();
const tracer = trace.getTracer("mcp-ticketing");
export async function withSpan<T>(
name: string,
attrs: Record<string, string | number>,
fn: () => Promise<T>,
): Promise<T> {
return tracer.startActiveSpan(name, { attributes: attrs }, async (span) => {
try {
const result = await fn();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
}
Обгортайте кожен tool call:
async (args, extra) => {
return withSpan(
"tool.search_tickets",
{ "mcp.client": extra.sessionId, "query.len": args.query.length },
async () => {
const results = await searchTickets(args, extra.sessionId);
return {
content: [{ type: "text", text: JSON.stringify(results) }],
};
},
);
},
Docker та local dev
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD node dist/healthcheck.js || exit 1
CMD ["node", "dist/index.js"]
І compose-файл з Redis та fake OIDC issuer для local dev:
services:
mcp-ticketing:
build: .
ports:
- "8080:8080"
environment:
OIDC_ISSUER: http://oidc-mock:4444/
OIDC_JWKS_URL: http://oidc-mock:4444/.well-known/jwks.json
REDIS_URL: redis://redis:6379
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
depends_on: [redis, oidc-mock, otel-collector]
redis:
image: redis:7-alpine
oidc-mock:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- "4444:4444"
otel-collector:
image: otel/opentelemetry-collector-contrib:0.112.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
Розгортання у Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-ticketing
namespace: ai-platform
spec:
replicas: 3
selector:
matchLabels: { app: mcp-ticketing }
template:
metadata:
labels: { app: mcp-ticketing }
spec:
containers:
- name: server
image: ghcr.io/example/mcp-ticketing:1.2.0
ports:
- containerPort: 8080
env:
- name: OIDC_ISSUER
value: https://auth.example.com/
- name: OIDC_JWKS_URL
value: https://auth.example.com/.well-known/jwks.json
- name: REDIS_URL
valueFrom: { secretKeyRef: { name: mcp-redis, key: url } }
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.observability:4318
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /live, port: 8080 }
periodSeconds: 15
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata:
name: mcp-ticketing
namespace: ai-platform
spec:
selector: { app: mcp-ticketing }
ports:
- port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mcp-ticketing
namespace: ai-platform
annotations:
cert-manager.io/cluster-issuer: letsencrypt
spec:
tls:
- hosts: [mcp-ticketing.ai.example.com]
secretName: mcp-ticketing-tls
rules:
- host: mcp-ticketing.ai.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mcp-ticketing
port: { number: 80 }
За чим стежити у продакшні
- p95 latency tool call. Ваш бекенд, ймовірно, є вузьким місцем, а не MCP-шар.
- Error rate tool call per tool. Сплеск в одному інструменті зазвичай означає, що нова модель викликає його неправильно.
- Rate limit rejections per client. Вказує або на зловживання, або на агента в retry loop.
- Auth failures. Прострочені токени, відсутні scopes — каже вам, що клієнтам потрібна інструкція.
- Помилки schema validation. LLM іноді вигадують аргументи. Відстежуйте частоту.
Типові помилки
- Експонування надто багато. Кожен інструмент множить attack surface. Починайте з read-only.
- Немає авторизації всередині handlers. Transport auth недостатньо — перевіряйте scope per action.
- Повернення гігантських блобів. Моделі мають скінченний контекст. Пагінуйте, підсумовуйте, фільтруйте.
- Забута ідемпотентність. Write tools можуть бути викликані двічі на retry. Використовуйте idempotency keys.
- Немає audit log. Кожна write-дія має бути залогована з principal, аргументами та outcome.
Наступні кроки
MCP — це куди йде інтеграція інструментів з LLM, і інвестування тижня, щоб експонувати ваші внутрішні API через production-grade MCP-сервер, окупиться багаторазово, коли агенти — ваші і ваших клієнтів — зможуть безпечно їх використовувати. Почніть з єдиного read-only інструменту, правильно налаштуйте auth та observability і розширюйтесь звідти. Якщо хочете допомоги з проєктуванням MCP-серверів для вашої платформи або аудитом існуючого, напишіть нам.