포스트

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

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

들어가며

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) 초기 셋업

1
2
3
4
5
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 + 안전장치)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// 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");
});

실행:

1
2
3
4
5
6
7
8
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

예상 로그(요약):

1
2
{"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)

1
2
3
4
5
6
7
8
9
10
11
12
// .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)

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.