Joyuno's Dev Blog

Claude + MCP 서버 구현(2026년 8월판): “에이전트 확장 서버”를 프로덕션에 올리는 설계/코드/함정 총정리

AI Agent

들어가며

MCP(Model Context Protocol)는 LLM/Agent가 외부 Tool·Resource·Prompt를 표준 인터페이스로 호출하게 만드는 프로토콜입니다. 2026년 현재 Claude 생태계에서는 Claude Desktop/Claude Code가 MCP 클라이언트로서 널리 쓰이고, VS Code도 MCP 서버를 “도구 공급자”로 통합하는 흐름이 강해졌습니다. (code.claude.com)

이 글이 해결하려는 구체적 문제

  • “우리 팀 내부 시스템(사내 DB, 배포 파이프라인, 모니터링, 문서/위키)을 Claude/Agent가 직접 쓰게” 만들고 싶은데,
  • 단순 REST API 노출이 아니라 (1) 안전한 권한/스코프, (2) 세션/상태, (3) 도구 수가 늘어날 때의 선택 정확도, (4) IDE/로컬/서버 배포까지의 운용을 같이 설계해야 함.

언제 쓰면 좋나

  • 에이전트가 “질문→조회→변환→검증→후속 액션”을 반복하며, 툴 호출이 핵심 가치일 때
  • 여러 클라이언트(Claude Code, Desktop, VS Code, 기타 MCP 클라이언트)에서 같은 기능을 재사용하고 싶을 때 (modelcontextprotocol.io)
  • 툴/리소스 목록이 자주 바뀌어 동적 갱신(list_changed) 이 필요한 경우 (code.claude.com)

언제 쓰면 안 되나

  • 단발성 자동화(예: cron 같은 배치)라면 MCP보다 “그냥 스크립트/Job”이 더 단순
  • 팀이 아직 권한·감사로그·데이터 노출 정책이 준비 안 됐는데 “에이전트에게 운영 권한”을 주려는 경우(사고 확률이 큼)
  • 도구가 너무 많아질 때 오히려 성능/정확도가 떨어질 수 있음(연구에서 “tool-selection accuracy vs tool-count” 저하를 지적) (arxiv.org)

🔧 핵심 개념

1) MCP가 제공하는 primitive: Tools / Resources / Prompts

  • Tools: 함수 호출(입력 스키마/설명/결과). 에이전트가 “행동(action)”을 수행하는 단위.
  • Resources: 에이전트가 참고할 수 있는 “주소가 있는 컨텍스트”(예: @server:.../schema, @server:.../runbook). Claude Code는 @로 리소스를 탐색/참조하는 UX를 제공합니다. (code.claude.com)
  • Prompts: 재사용 가능한 프롬프트 템플릿(서버가 “작업 프레임”을 제공).

핵심은 “툴만 던져주면 끝”이 아니라, 리소스로 근거를 제공하고, 프롬프트로 작업 절차를 고정해 에이전트의 일관성을 올리는 구조가 가능하다는 점입니다.

2) 내부 작동 방식(흐름): 세션 + JSON-RPC 스타일 호출 + 알림

MCP는 기본적으로 클라이언트가 서버에 연결(transport는 stdio/SSE/HTTP 등)하고, 서버가 제공하는 도구/리소스/프롬프트를 “카탈로그처럼” 노출합니다. Claude Code 문서에는 stdio/SSE/HTTP 등 여러 transport와, 서버가 도구 목록 변경을 알리는 list_changed notification 같은 동적 갱신이 언급됩니다. (code.claude.com)

운영 관점에서 중요한 포인트:

  • stdio: 로컬에서 클라이언트가 서버 프로세스를 서브프로세스로 띄우는 방식(가장 단순, 개발/개인 환경에 최적) (grafana.com)
  • SSE / streamable-http: 원격 서버/공유 환경에서 적합(네트워크/인증/로드밸런싱 고려 필요) (grafana.com)
  • 디버깅/관찰성: stdio는 stderr 로그, 모든 transport에서 notification 기반 로그도 활용 가능(세션 헤더/스트림 관찰이 중요) (modelcontextprotocol.io)

3) “에이전트 확장 서버” 구축 관점: 단일 툴 제공이 아니라 ‘패턴’ 선택

최근 경험/연구 기반으로 MCP 서버 아키텍처 패턴을 분류하는 흐름이 있습니다. 예를 들어 Resource Gateway / Tool Orchestrator / Stateful Session Server / Proxy Aggregator / Domain-Specific Adapter 같은 패턴이 반복된다고 정리합니다. (arxiv.org)
실무에선 보통:

  • 내부 시스템이 많으면 Proxy Aggregator(내부 API들을 감싸는 단일 MCP 서버) 로 시작
  • 도구 호출이 연쇄되고 상태가 필요하면 Stateful Session Server 성격이 강해짐
  • 특정 도메인(예: 배포/장애대응)만 다루면 Domain-Specific Adapter가 유지보수에 유리

💻 실전 코드

시나리오: “운영자가 Claude Code에서 서비스 장애 원인 추정 → 최근 배포/에러율 조회 → 롤백 PR 생성까지”를 수행하고 싶다.
여기서는 (1) Observability 조회, (2) 배포 이력 조회, (3) 롤백 브랜치/PR 생성을 MCP Tools로 제공하고, runbook/resource를 MCP Resources로 제공합니다.

아래 예제는 TypeScript SDK 기반 MCP 서버(stdio)로, Claude Code가 로컬에서 띄우는 형태입니다. TypeScript SDK의 서버 구성 요소는 공식 가이드에 정리되어 있습니다. (github.com)

1) 초기 셋업 (Node + 의존성)

# Node 20+ 권장(팀 표준에 맞추세요)
mkdir ops-mcp && cd ops-mcp
npm init -y

# MCP TypeScript SDK (서버)
npm i @modelcontextprotocol/server zod
npm i -D typescript tsx @types/node

npx tsc --init

2) 서버 구현: Tools + Resources + 최소한의 안전장치

// src/server.ts
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
import { z } from "zod";

type Env = {
  GRAFANA_URL: string;
  GRAFANA_TOKEN: string;
  DEPLOY_API_URL: string;
  DEPLOY_API_TOKEN: string;
  GIT_API_URL: string;
  GIT_TOKEN: string;
};

function mustEnv(name: keyof Env): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing env: ${name}`);
  return v;
}

const env: Env = {
  GRAFANA_URL: mustEnv("GRAFANA_URL"),
  GRAFANA_TOKEN: mustEnv("GRAFANA_TOKEN"),
  DEPLOY_API_URL: mustEnv("DEPLOY_API_URL"),
  DEPLOY_API_TOKEN: mustEnv("DEPLOY_API_TOKEN"),
  GIT_API_URL: mustEnv("GIT_API_URL"),
  GIT_TOKEN: mustEnv("GIT_TOKEN"),
};

// --- 예시용: 실제론 각 사내 시스템 SDK/클라이언트를 쓰세요 ---
async function fetchJson(url: string, headers: Record<string, string>) {
  const res = await fetch(url, { headers });
  if (!res.ok) throw new Error(`HTTP ${res.status} ${await res.text()}`);
  return res.json();
}

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

// Resource: 장애 대응 runbook 제공(클라이언트가 @로 참조 가능)
server.resource(
  "runbook",
  new ResourceTemplate("runbook://{service}", { list: ["payments", "api", "web"] }),
  async ({ service }) => {
    // 현실: 파일/위키/문서 저장소에서 읽기 + 캐시
    const text =
      service === "payments"
        ? `Payments Runbook:
- Check error rate panel
- Check last deploy SHA
- If regression suspected: create rollback PR
- Verify DB latency & queue depth`
        : `${service} Runbook: (placeholder)`;

    return {
      contents: [
        {
          uri: `runbook://${service}`,
          mimeType: "text/plain",
          text,
        },
      ],
    };
  }
);

// Tool 1: 최근 N분 에러율 조회(Grafana/Prometheus 등)
server.tool(
  "get_error_rate",
  {
    description:
      "Get error rate for a service in the last N minutes. Use before proposing rollback.",
    inputSchema: z.object({
      service: z.string().min(1),
      minutes: z.number().int().min(1).max(180),
    }),
  },
  async ({ service, minutes }) => {
    // 예: Grafana DataSource Query API로 대체 가능(조직에 맞게)
    const url = `${env.GRAFANA_URL}/api/fake/error-rate?service=${encodeURIComponent(
      service
    )}&minutes=${minutes}`;

    const data = await fetchJson(url, {
      Authorization: `Bearer ${env.GRAFANA_TOKEN}`,
    });

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              service,
              windowMinutes: minutes,
              errorRate: data.errorRate,
              samples: data.samples,
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

// Tool 2: 최근 배포 이력 조회
server.tool(
  "get_recent_deploys",
  {
    description:
      "List recent deploys for a service, including commit SHA and timestamp. Use to correlate with spikes.",
    inputSchema: z.object({
      service: z.string().min(1),
      limit: z.number().int().min(1).max(20).default(5),
    }),
  },
  async ({ service, limit }) => {
    const url = `${env.DEPLOY_API_URL}/deploys?service=${encodeURIComponent(
      service
    )}&limit=${limit}`;

    const data = await fetchJson(url, {
      Authorization: `Bearer ${env.DEPLOY_API_TOKEN}`,
    });

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

// Tool 3: 롤백 PR 생성(보수적으로 “제안”을 자동화)
server.tool(
  "create_rollback_pr",
  {
    description:
      "Create a rollback pull request from current deploy to previous stable SHA. This is a high-impact tool; use only when evidence supports regression.",
    inputSchema: z.object({
      repo: z.string().min(1), // "org/repo"
      fromSha: z.string().min(7),
      toSha: z.string().min(7),
      reason: z.string().min(10),
    }),
  },
  async ({ repo, fromSha, toSha, reason }) => {
    // 현실: GitHub/GitLab API. 여기서는 사내 Git API를 가정.
    const url = `${env.GIT_API_URL}/repos/${encodeURIComponent(repo)}/rollback-pr`;

    const body = {
      fromSha,
      toSha,
      title: `Rollback: ${fromSha.slice(0, 7)} -> ${toSha.slice(0, 7)}`,
      description: `Automated rollback proposal.\n\nReason:\n${reason}`,
    };

    const pr = await fetchJson(url, {
      Authorization: `Bearer ${env.GIT_TOKEN}`,
      "Content-Type": "application/json",
    });

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              repo,
              prUrl: pr.url,
              prNumber: pr.number,
              fromSha,
              toSha,
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

server.startStdio();

3) Claude Code에 연결(로컬 stdio)

Claude Code 문서 기준으로 MCP 서버를 stdio로 붙이는 CLI 흐름이 안내되어 있습니다. (code.claude.com)

# 프로젝트 루트에서
export GRAFANA_URL="https://grafana.example.com"
export GRAFANA_TOKEN="***"
export DEPLOY_API_URL="https://deploy.example.com"
export DEPLOY_API_TOKEN="***"
export GIT_API_URL="https://git.example.com"
export GIT_TOKEN="***"

# 서버 실행(개발)
npx tsx src/server.ts

Claude Code가 서버를 직접 띄우게 하려면(권장: 팀 정책에 맞춰 명령 허용/차단):

claude mcp add --transport stdio ops-mcp -- npx tsx /ABS/PATH/ops-mcp/src/server.ts

예상 사용 흐름(Claude Code 프롬프트):

  • @ops-mcp:runbook://payments 를 먼저 읽게 해서 절차를 고정 (code.claude.com)
  • get_error_rate(service="payments", minutes=30)
  • get_recent_deploys(service="payments", limit=5)
  • 근거가 충분하면 create_rollback_pr(repo="org/payments", fromSha="...", toSha="...", reason="...")

⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) Tool 수를 의도적으로 제한하고 “도메인별 서버 분리”를 고려

  • 도구가 10~20개 넘어가면 모델의 tool 선택 정확도가 떨어질 수 있다는 보고가 있습니다. 서버를 “ops / data / docs”처럼 분리하거나, 하나의 서버 안에서도 “entry tool(라우터)”로 묶는 전략이 필요합니다. (arxiv.org)

2) Resources를 ‘근거 저장소’로 만들고, Tool은 ‘행동’에만 집중

  • runbook, 스키마, 운영 규칙, 금지사항(예: “DB write 금지”)을 resource로 제공하면, 에이전트가 매번 같은 안전 규칙을 참조하게 만들 수 있습니다(Claude Code의 @ 참조 UX 활용). (code.claude.com)

3) 관찰성(로그/세션)부터 설계

  • 디버깅 문서가 강조하는 것처럼 transport별로 요청/세션/스트림을 추적할 수 있어야 합니다. 특히 stdio는 stderr 로그가 생명줄이고, HTTP 계열은 헤더/세션 ID 관찰이 핵심입니다. (modelcontextprotocol.io)

흔한 함정/안티패턴

  • “툴 설명(description) 대충 쓰기”: 에이전트 성능이 툴 설명 품질에 크게 좌우된다는 문제 제기가 있습니다. 입력 스키마뿐 아니라 “언제 이 툴을 써야/말아야 하는지, 어떤 근거가 필요한지”를 description에 넣어야 합니다. (arxiv.org)
  • 고위험 툴을 바로 실행형으로 만들기: delete_user, rollback_prod_now 같은 툴을 제공하면 언젠가 사고 납니다. “PR 생성/승인 요청”처럼 인간 승인 단계로 설계하세요(위 코드처럼).
  • 원격 transport로 가면서 인증/권한을 뒤늦게 붙이기: SSE/HTTP는 공유가 쉬운 만큼, 토큰 스코프·감사로그·레이트리밋이 선행돼야 합니다. (grafana.com)

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

  • stdio(로컬): 빠르고 단순하지만 팀 공유/중앙 통제가 어렵고, 각 개발자 환경 의존성이 생김
  • SSE/streamable-http(원격): 운영은 표준적이지만 네트워크 지연, 인증, 멀티테넌시, 장애 대응 비용이 증가 (grafana.com)
  • Stateful 설계: 세션 캐시로 성능을 얻지만, 서버 재시작/스케일아웃 시 세션 동기화 비용이 생김(필요할 때만)

🚀 마무리

정리하면, 2026년 8월 시점의 MCP 서버 구현은 “툴 몇 개 붙이기”가 아니라 에이전트 확장(Agent Extension) 인프라를 만드는 작업에 가깝습니다. Claude Code/VS Code 등 클라이언트가 MCP를 1급 도구로 다루기 시작하면서, 서버는 곧 “조직의 운영 지식과 액션을 표준화”하는 레이어가 됩니다. (code.claude.com)

도입 판단 기준

  • (O) 반복되는 업무가 있고, 조회/검증/후속 액션이 명확하며, 감사/권한/승인 절차를 설계할 수 있다
  • (X) 고위험 운영 액션을 무제한 자동화하려 한다 / 도구가 무한히 늘어나는데 정리할 계획이 없다 / 로그·보안이 없다

다음 학습 추천

  • 공식 SDK(Server API) 문서로 “tool/resource/prompt + transport”를 정확히 익히고 (github.com)
  • Debugging 가이드대로 세션/로그/스트림을 관찰하는 습관을 먼저 만들고 (modelcontextprotocol.io)
  • Extensions(확장 메커니즘)는 “호환성 유지(negotiation) + 점진적 도입” 관점에서 접근하세요. (blog.modelcontextprotocol.io)