Joyuno's Dev Blog

MCP 서버 구현, 2026년 5월 기준 “Claude용 에이전트 확장 서버”를 프로덕션에 올리는 법

AI Agent

들어가며

LLM/agent를 제품에 붙이다 보면 매번 같은 벽을 만납니다. “모델은 똑똑한데, 우리 시스템(DB/사내 API/권한/감사로그/업무 규칙) 안으로 안전하게 들어오게 하려면?” 대부분 팀이 모델별 custom tool-calling이나 SDK별 플러그인을 각자 구현하다가, 클라이언트(Claude Desktop/Claude Code/Cursor/VS Code/ChatGPT 등)와 툴 서버 간 결합이 폭발합니다.

Model Context Protocol(MCP)는 이 지점을 겨냥합니다. “도구/컨텍스트 제공”을 표준화해, 한 번 MCP server를 만들면 여러 MCP client가 재사용하게 하는 구조입니다. Anthropic 문서에서도 Claude Code가 MCP로 외부 툴/데이터소스에 연결된다고 명시합니다. (docs.anthropic.com)

언제 쓰면 좋나?

  • 여러 클라이언트(Claude Code, Claude Desktop, Cursor 등)에서 동일한 사내 도구 묶음을 공유하고 싶을 때
  • 툴 호출을 권한/감사/레이트리밋/안전장치까지 포함해 “서버 경계”로 만들고 싶을 때
  • 로컬 자동화(파일/깃/스크립트)와 원격 SaaS(API) 통합을 같은 인터페이스로 묶고 싶을 때

언제 쓰면 안 되나?

  • 단일 앱 내부에서만 쓰는 단발성 tool-calling이면, 굳이 MCP로 “서버/스키마/버전”까지 운영하는 비용이 과합니다.
  • 강한 보안 요구(특히 로컬 stdio 기반)에서 프로세스 실행/권한 위임을 제대로 샌드박싱할 자신이 없다면, MCP 도입이 오히려 공격면을 키울 수 있습니다. 2026년에는 MCP 생태계 전반에 보안 이슈(악성 서버/설계 취약점 논란)가 공개적으로 논의되었습니다. (techradar.com)

🔧 핵심 개념

1) MCP의 구성요소: Tool / Resource / Prompt + Transport

MCP 서버는 대체로 세 가지 “제공물”을 노출합니다.

  • Tools: 모델이 실행을 요청하는 동작(부작용 가능: 네트워크 호출/DB 업데이트 등)
  • Resources: 읽기 전용 데이터(문서/레코드/설정 등)
  • Prompts: 재사용 가능한 템플릿(일관된 작업 지시)

TypeScript SDK 문서도 이 3요소를 핵심으로 잡고 있고, 서버는 McpServer로 만들고 transport를 붙입니다. (ts.sdk.modelcontextprotocol.io)

Transport는 2026년 기준 실무적으로 두 축입니다.

  • stdio: 클라이언트가 서버를 로컬 서브프로세스로 실행(개발 편함, 권한 문제 큼)
  • Streamable HTTP: 원격 서버 운영의 사실상 표준(권장) (ts.sdk.modelcontextprotocol.io)

2) 내부 동작 흐름(“서버는 에이전트의 확장 런타임”)

실전에서 중요한 건 “Tool 호출이 단순 RPC가 아니라, 에이전트 루프의 일부”라는 점입니다.

대략 흐름은:

  1. MCP client가 서버에 initialize(프로토콜 버전/기능 협상)
  2. client가 서버에서 제공하는 tool 목록/스키마를 “모델에게” 알려줌
  3. 모델이 특정 tool을 선택해 호출(입력은 JSON schema 기반)
  4. 서버는 실제 업무 시스템 호출 → 결과 반환
  5. 결과가 다시 대화 컨텍스트로 들어가 다음 추론에 영향

Streamable HTTP는 여기에 session id(헤더 MCP-Session-Id)와 프로토콜 버전 헤더(MCP-Protocol-Version) 같은 운영 요소가 붙습니다. 이게 “로드밸런싱/관측/리줌” 설계를 좌우합니다. (modelcontextprotocol.io)

3) 2026년 5월 관점에서 “왜 지금 MCP 서버를 다시 설계해야 하나”

2026년 MCP는 단순 로컬 툴 연결을 넘어 원격/엔터프라이즈 운영으로 이동 중입니다. 공식 블로그에서 “HTTP 인프라에서 스케일”을 강조하고, 2026 로드맵/RC에서 stateless core, OAuth/OIDC 정렬, Tasks 같은 확장 방향이 언급됩니다. (blog.modelcontextprotocol.io)
즉, “내가 만드는 MCP server”는 이제 로컬 스크립트 래퍼가 아니라:

  • 멀티테넌트/권한
  • 감사로그/레이트리밋
  • 세션/재시도/타임아웃
  • 위험도 표시(어노테이션)
    까지 포함한 agent extension server로 보는 게 맞습니다. (툴 어노테이션이 리스크 vocabulary로 다뤄지는 것도 같은 맥락) (blog.modelcontextprotocol.io)

💻 실전 코드

아래는 “현실적인 시나리오”로, 사내 티켓 시스템(Jira 유사) + Runbook(문서) + 승인 게이트를 하나의 MCP server로 묶습니다.

목표:

  • search_tickets(read-only): 최근 이슈 검색
  • create_deploy_task(side-effect): 배포 태스크 생성(단, 운영환경은 approval 필요)
  • Streamable HTTP로 원격 운영(Claude Desktop/Claude Code/기타 클라이언트에서 연결 가능)

1) 초기 셋업

mkdir mcp-ops-server && cd mcp-ops-server
npm init -y
npm install @modelcontextprotocol/sdk zod express pino undici
npm install -D typescript tsx @types/node @types/express
npx tsc --init

2) 서버 구현 (Streamable HTTP + 안전장치)

// src/server.ts
import express from "express";
import pino from "pino";
import { z } from "zod";
import { request } from "undici";

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";

const log = pino({ level: process.env.LOG_LEVEL ?? "info" });

/**
 * 현실 포인트:
 * - "툴 구현"은 결국 외부 API 호출 + 정책(권한/승인/레이트리밋)이다.
 * - 모델이 임의로 prod 배포 같은 부작용을 일으키면 끝장나므로, 서버에서 강제 게이트를 둔다.
 */

const Env = z.object({
  PORT: z.coerce.number().default(8787),
  TICKETS_API_BASE: z.string().url(),
  TICKETS_API_TOKEN: z.string().min(1),
  DEPLOY_API_BASE: z.string().url(),
  DEPLOY_API_TOKEN: z.string().min(1),
  // 예: "prod"면 승인 필요
  REQUIRE_APPROVAL_FOR_ENVS: z.string().default("prod"),
  // 간단한 shared secret (실무는 OAuth/OIDC 권장)
  MCP_AUTH_TOKEN: z.string().min(1),
});
const env = Env.parse(process.env);

type AuthedContext = { actor: string };

// 간단 auth: 헤더 토큰 → actor 식별
function authFromReq(req: express.Request): AuthedContext {
  const token = req.header("authorization")?.replace(/^Bearer\s+/i, "");
  if (!token || token !== env.MCP_AUTH_TOKEN) {
    throw Object.assign(new Error("Unauthorized"), { statusCode: 401 });
  }
  // 실무: 토큰에 사용자/팀/스코프 클레임 넣고 RBAC 결정
  return { actor: "mcp-client" };
}

// 외부 API helper
async function httpJson<T>(url: string, options: any): Promise<T> {
  const res = await request(url, {
    ...options,
    headers: { "content-type": "application/json", ...(options.headers ?? {}) },
  });
  if (res.statusCode >= 400) {
    const body = await res.body.text();
    throw new Error(`HTTP ${res.statusCode} ${url}: ${body}`);
  }
  return (await res.body.json()) as T;
}

const server = new McpServer({
  name: "ops-extension",
  version: "2026.05.0",
});

// Tool 1) 티켓 검색 (read-only)
server.tool(
  "search_tickets",
  "Search recent tickets by text and status. Returns compact fields for agent routing.",
  {
    q: z.string().min(2).describe("Search query"),
    status: z.enum(["open", "in_progress", "done", "any"]).default("any"),
    limit: z.number().int().min(1).max(20).default(10),
  },
  async ({ q, status, limit }) => {
    const url = new URL("/v1/tickets/search", env.TICKETS_API_BASE);
    url.searchParams.set("q", q);
    url.searchParams.set("status", status);
    url.searchParams.set("limit", String(limit));

    const data = await httpJson<{ items: Array<any> }>(url.toString(), {
      method: "GET",
      headers: { authorization: `Bearer ${env.TICKETS_API_TOKEN}` },
    });

    // 모델에 과다 데이터를 주지 않도록 “요약 필드만”
    const items = data.items.map((t) => ({
      id: t.id,
      title: t.title,
      status: t.status,
      priority: t.priority,
      updatedAt: t.updated_at,
      url: t.url,
    }));

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({ items }, null, 2),
        },
      ],
    };
  }
);

// Tool 2) 배포 태스크 생성 (side-effect + 승인 게이트)
server.tool(
  "create_deploy_task",
  "Create a deploy task. For prod, require explicit approvalToken issued by your change-management system.",
  {
    service: z.string().min(1),
    gitRef: z.string().min(3).describe("Commit SHA or tag"),
    env: z.enum(["dev", "staging", "prod"]),
    changeReason: z.string().min(10),
    approvalToken: z.string().optional().describe("Required for prod"),
  },
  async (input) => {
    const requireApprovalEnvs = new Set(
      env.REQUIRE_APPROVAL_FOR_ENVS.split(",").map((s) => s.trim())
    );

    if (requireApprovalEnvs.has(input.env) && !input.approvalToken) {
      // 핵심: “모델에게 다시 물어보게” 만들지 말고,
      // 서버에서 명확한 에러 계약을 반환해 워크플로우를 강제.
      return {
        isError: true,
        content: [
          {
            type: "text",
            text:
              "approvalToken is required for this environment. " +
              "Obtain approval via change-management flow and retry.",
          },
        ],
      };
    }

    const task = await httpJson<{ id: string; url: string }>(
      `${env.DEPLOY_API_BASE}/v1/deploy/tasks`,
      {
        method: "POST",
        headers: { authorization: `Bearer ${env.DEPLOY_API_TOKEN}` },
        body: JSON.stringify({
          service: input.service,
          gitRef: input.gitRef,
          env: input.env,
          reason: input.changeReason,
          approvalToken: input.approvalToken ?? null,
        }),
      }
    );

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              created: true,
              taskId: task.id,
              url: task.url,
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// Express 앱 (DNS rebinding protection 포함 옵션이 기본으로 켜짐)
const app = createMcpExpressApp({
  // host 바인딩에 따라 보호가 달라질 수 있으니 운영 시 의식적으로 결정
  host: "0.0.0.0",
});

// 공통 미들웨어: auth + 로깅
app.use((req, res, next) => {
  try {
    const ctx = authFromReq(req);
    (req as any).mcpActor = ctx.actor;
    next();
  } catch (e: any) {
    res.status(e.statusCode ?? 500).json({ error: e.message ?? "error" });
  }
});

app.use((req, _res, next) => {
  log.info(
    {
      method: req.method,
      path: req.path,
      session: req.header("mcp-session-id"),
      actor: (req as any).mcpActor,
    },
    "mcp_request"
  );
  next();
});

// MCP 엔드포인트 연결
app.post("/mcp", async (req, res) => {
  await server.handleHttpRequest(req, res);
});
app.get("/mcp", async (req, res) => {
  await server.handleHttpRequest(req, res);
});

app.listen(env.PORT, () => {
  log.info({ port: env.PORT }, "MCP ops-extension listening");
});

실행:

export PORT=8787
export TICKETS_API_BASE="https://tickets.example.com"
export TICKETS_API_TOKEN="..."
export DEPLOY_API_BASE="https://deploy.example.com"
export DEPLOY_API_TOKEN="..."
export MCP_AUTH_TOKEN="dev-secret"
npm run -s build || true
npx tsx src/server.ts

예상 로그(요약):

{"level":"info","port":8787,"msg":"MCP ops-extension listening"}
{"level":"info","method":"POST","path":"/mcp","session":"...","actor":"mcp-client","msg":"mcp_request"}

3) Claude Code에 붙이는 방법(프로젝트 스코프 예시)

Claude Code는 .mcp.json에 서버를 등록할 수 있고, 팀 공유는 project scope가 핵심입니다. (docs.anthropic.com)

// .mcp.json
{
  "mcpServers": {
    "ops-extension": {
      "type": "sse",
      "url": "http://localhost:8787/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_AUTH_TOKEN}"
      }
    }
  }
}

(주의) 실제로 어떤 클라이언트는 Streamable HTTP의 특정 모드/호환성에 민감할 수 있어, 클라이언트 요구 transport 설정을 확인해야 합니다. 프로토콜/클라이언트 버전 불일치로 서버가 깨지는 사례도 커뮤니티에서 보고됩니다(예: 프로토콜 버전 변경). (reddit.com)


⚡ 실전 팁 & 함정

Best Practice (프로덕션에서 체감 큰 것들)

1) “툴 계약”을 좁혀라: 결과는 요약 필드만

  • MCP는 모델 컨텍스트로 결과가 들어가 비용을 만듭니다.
  • 티켓 검색 결과처럼 “모델 라우팅에 필요한 최소 필드”만 주고, 상세는 get_ticket_detail 같은 별도 툴로 쪼개세요.

2) Side-effect 툴은 서버에서 정책 강제(approval, RBAC, rate limit)

  • “프롬프트로 조심하라”는 안전장치가 아닙니다.
  • 위 코드처럼 prod에선 approvalToken 없으면 실패시키는 식으로 서버 경계에서 제어하세요.

3) Streamable HTTP에서 세션을 ‘상태’로 쓰지 말고 ‘힌트’로 써라

  • spec상 session id는 가능하지만, 운영/스케일을 생각하면 서버를 가능하면 stateless로 유지하고(캐시/DB로 상태 외부화), 세션은 관측/리줌 정도로만 쓰는 게 안전합니다. (modelcontextprotocol.io)

흔한 함정/안티패턴

  • stdio 기반 “로컬 만능 서버”를 무분별하게 배포: MCP 생태계는 로컬 프로세스 실행과 권한 문제가 자주 논의됩니다. 실제로 보안 논란(설계상 RCE 가능성 주장, 악성 MCP 서버 사례)이 공개되었습니다. (tomshardware.com)
    → 최소한: allowlist, 샌드박싱, 네트워크 egress 제한, 토큰 최소권한, 마켓/레포 검증이 필요합니다.
  • 버전 핀닝 없이 SDK/프로토콜 업데이트: 클라이언트가 프로토콜 버전을 바꾸면(헤더 MCP-Protocol-Version) 예전 서버/SDK가 깨질 수 있습니다. (modelcontextprotocol.io)
  • Tool 이름/스키마가 자주 바뀌는 서버: 에이전트는 “학습된 사용 습관”을 갖기 때문에, 스키마 변동은 곧 품질 하락입니다. 변경은 deprecation 정책을 두고 천천히.

비용/성능/안정성 트레이드오프

  • 원격 MCP server는 결국 API 제품입니다: p95 latency, 타임아웃, 재시도, 부분 실패 설계가 없으면 에이전트 경험이 급격히 나빠집니다.
  • “모델이 한 번에 다 해결”하려 할수록 tool 호출 횟수/토큰이 늘어납니다 → 작은 도구 여러 개 vs 큰 도구 한 개의 균형을 잡아야 합니다(보통은 “작게 쪼개되, 흔한 작업은 합성 도구 1개”가 운영에 유리).

🚀 마무리

2026년 5월 기준 MCP는 “도구 연결 표준”을 넘어, 에이전트 확장 서버를 제품화하는 쪽으로 무게중심이 이동 중입니다(원격/HTTP 스케일, 확장(Tasks/Apps) 논의, 기업 채택 확대). (blog.modelcontextprotocol.io)
도입 판단 기준은 간단합니다.

  • 여러 클라이언트/에이전트가 동일한 툴을 공유해야 하고
  • 권한/승인/감사/안전장치를 “프롬프트”가 아니라 “서버 경계”에서 강제해야 하며
  • 장기적으로 툴 생태계를 확장할 계획이 있다면
    → MCP server로 가는 게 맞습니다.

다음 학습 추천(실무 순서): 1) MCP spec의 transport/session/version 헤더를 먼저 정독(운영 이슈의 80%가 여기서 납니다). (modelcontextprotocol.io)
2) TypeScript SDK의 Streamable HTTP 예제 기반으로 “auth/observability/rate limit”을 넣어 프로덕션 템플릿화. (ts.sdk.modelcontextprotocol.io)
3) Claude Code 쪽에서는 프로젝트 스코프 .mcp.json로 팀 배포 + 승인 UX를 설계. (docs.anthropic.com)

원하면, 위 예제를 (1) OAuth/OIDC로 교체하거나 (2) MCP Mediator(서버가 다른 MCP 서버를 호출하는 패턴) 형태로 확장(사내 “툴 허브”)하는 설계까지 이어서 작성해드릴게요. (arxiv.org)