Loading...
Усі статті
AI Infrastructure · 9 min read

Гайд з реалізації MCP-сервера: Model Context Protocol для продакшну

Побудова production-grade Model Context Protocol сервера на TypeScript з автентифікацією, rate limiting, observability та розгортанням у Kubernetes.

Чому 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% специфікації.

  1. Server. Процес, що експонує можливості.
  2. Client. LLM-застосунок, що підключається до серверів.
  3. Tools. Викликувані функції з типізованими аргументами, які модель може інвокувати.
  4. Resources. Read-only джерела даних (файли, записи, сторінки), які модель може включити в контекст.
  5. 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-транспортів. На практиці у вас три варіанти:

  1. OAuth 2.1 з PKCE — правильно, гнучко, найбільше роботи.
  2. OIDC з вашого існуючого IdP — добре, якщо ваші клієнти вже мають user identity.
  3. Короткоживучі 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-серверів для вашої платформи або аудитом існуючого, напишіть нам.

у категорії
mcpmodel-context-protocolaiinfrastructure
працювати з нами

Хочете, щоб наша команда допомогла з вашою інфраструктурою?

talk to an engineerFree 30-min discovery callBook
close