Skip to main content
Summary

장난감 서버와 실무 서버의 차이

앞서 만든 첫 MCP 서버는 계산기처럼 안전한 기능으로 시작했다. 실무 서버는 다르다. 외부 API를 호출하고, 데이터베이스를 조회하고, 사내 시스템의 상태를 읽는다. 때로는 티켓을 만들거나 메시지 초안을 저장한다.

이때 중요한 질문은 "무엇을 연결할 수 있는가"가 아니다. "어떤 업무 단위로 안전하게 열어 줄 것인가"다.

나쁜 실무 서버는 내부 API 전체를 call_api 같은 Tool 하나로 노출한다. 모델은 엔드포인트, 메서드, 본문을 자유롭게 조합할 수 있다. 편해 보이지만 운영에서는 위험하다. 좋은 실무 서버는 업무 언어로 Tool을 나눈다.

  • support_search_tickets: 고객 지원 티켓을 검색한다.

  • support_get_ticket_detail: 특정 티켓의 상세 내용을 읽는다.

  • support_create_reply_draft: 답변 초안을 만든다.

  • support_submit_reply: 승인된 답변을 전송한다.

MCP Tool은 모델이 발견하고 호출할 수 있는 실행 단위다. 그래서 name, description, inputSchema, 필요하다면 outputSchema를 명확히 설계해야 한다. outputSchema를 제공한다면 Tool이 돌려주는 structuredContent도 그 스키마에 맞아야 한다. 실행 중 외부 API 실패나 업무 규칙 위반이 생기면 Tool 결과에 isError: true로 표현하고, 알 수 없는 Tool이나 잘못된 인자처럼 프로토콜 수준의 문제는 JSON-RPC 오류로 다루는 식의 구분도 필요하다.1

실무형 MCP 서버로 확장하기AI 생성 이미지

REST와 GraphQL은 업무 Tool로 감싼다

REST API를 MCP로 연결할 때 가장 쉬운 방법은 method, path, body를 입력받는 범용 Tool을 만드는 것이다. 하지만 이는 내부 API 콘솔을 모델에게 넘겨주는 것과 같다. 실무에서는 REST 엔드포인트를 그대로 노출하지 말고, 목적이 분명한 Tool로 감싼다.

예를 들어 고객 지원 시스템에 /tickets, /tickets/{id}, /tickets/{id}/comments API가 있다면 MCP Tool은 다음처럼 나누는 편이 낫다.

server.registerTool(
  "support_search_tickets",
  {
    title: "지원 티켓 검색",
    description:
      "고객 지원 티켓을 읽기 전용으로 검색합니다. 상태, 고객 이메일, 키워드로 최대 20건까지 반환합니다.",
    inputSchema: {
      status: z.enum(["open", "pending", "closed"]).optional(),
      customerEmail: z.string().email().optional(),
      keyword: z.string().max(100).optional(),
      limit: z.number().int().min(1).max(20).default(10),
    },
  },
  async ({ status, customerEmail, keyword, limit }) => {
    const result = await callSupportApiWithRetry(
      "/tickets/search",
      {
        status,
        customerEmail,
        keyword,
        limit,
      },
      { idempotent: true }
    );

    if (!result.ok) {
      return {
        isError: true,
        content: [{ type: "text", text: result.message }],
        structuredContent: {
          errorCode: result.code,
          retryable: result.retryable,
        },
      };
    }

    return {
      content: [
        {
          type: "text",
          text: `검색 결과 ${result.data.items.length}건을 찾았습니다.`,
        },
      ],
      structuredContent: result.data,
    };
  }
);

여기서 핵심은 API 호출 코드가 아니라 경계다. Tool은 검색만 한다. 댓글 작성이나 상태 변경을 섞지 않는다. limit도 모델이 마음대로 1,000건으로 늘릴 수 없게 서버에서 제한한다.

오류 처리도 업무적으로 나눠야 한다.

상황처리 방식
입력값 형식이 틀림스키마 검증 실패로 호출 자체를 거절
티켓이 없음Tool 실행 결과 isError: true, NOT_FOUND
권한 없음isError: true, FORBIDDEN, 재시도하지 않음
429 Rate LimitRetry-After를 보고 제한적으로 재시도
500, 502, 503짧은 지수 백오프로 재시도
400, 401, 403보통 재시도하지 않음

재시도는 모든 실패에 적용하면 안 된다. GET처럼 멱등적인 요청은 비교적 안전하다. 같은 요청을 여러 번 보내도 상태가 바뀌지 않기 때문이다. 반면 댓글 등록, 결제 요청, 상태 변경 같은 쓰기 작업은 재시도 전에 멱등성 키나 중복 방지 장치가 있어야 한다. HTTP 메서드가 POST이더라도 검색처럼 상태를 바꾸지 않는 API라면 서버 코드에서 "재시도해도 되는 작업"으로 명시하고, 실제 쓰기 작업은 더 보수적으로 다루는 편이 안전하다.

function retryAfterToMs(value: string | null) {
  if (!value) return null;

  const seconds = Number(value);
  if (!Number.isNaN(seconds)) {
    return Math.max(0, seconds * 1000);
  }

  const dateMs = Date.parse(value);
  if (!Number.isNaN(dateMs)) {
    return Math.max(0, dateMs - Date.now());
  }

  return null;
}

async function callSupportApiWithRetry(
  path: string,
  body: unknown,
  options: { idempotent: boolean }
) {
  const maxAttempts = 3;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 8000);
    let delayMs = 300 * 2 ** (attempt - 1);

    try {
      const res = await fetch(`${process.env.SUPPORT_API_URL}${path}`, {
        method: "POST",
        headers: {
          "content-type": "application/json",
          authorization: `Bearer ${process.env.SUPPORT_API_TOKEN}`,
        },
        body: JSON.stringify(body),
        signal: controller.signal,
      });

      if (res.ok) {
        return { ok: true as const, data: await res.json() };
      }

      const retryableStatus = [429, 500, 502, 503].includes(res.status);
      const canRetry = retryableStatus && options.idempotent && attempt < maxAttempts;

      if (!canRetry) {
        return {
          ok: false as const,
          code: `HTTP_${res.status}`,
          message: `지원 API 요청이 실패했습니다. 상태 코드: ${res.status}`,
          retryable: retryableStatus && options.idempotent,
        };
      }

      if (res.status === 429) {
        delayMs = retryAfterToMs(res.headers.get("retry-after")) ?? delayMs;
      }
    } catch (error) {
      if (attempt === maxAttempts) {
        return {
          ok: false as const,
          code: "NETWORK_ERROR",
          message: "지원 API에 연결하지 못했습니다.",
          retryable: options.idempotent,
        };
      }
    } finally {
      clearTimeout(timeout);
    }

    await sleep(Math.min(delayMs, 5000));
  }

  return {
    ok: false as const,
    code: "RETRY_EXHAUSTED",
    message: "여러 번 재시도했지만 요청이 실패했습니다.",
    retryable: true,
  };
}

GraphQL은 조금 다르다. HTTP 상태가 200이어도 응답 안에 errors가 들어올 수 있다. 또 일부 필드는 성공하고 일부 필드는 실패하는 부분 성공이 가능하다. 따라서 GraphQL Tool에서는 errors, data, extensions를 모두 확인하고, 모델에게 "전체 성공"인지 "부분 성공"인지 알려 줘야 한다.

실무 원칙은 같다. graphql_query 같은 범용 Tool을 만들기보다 sales_get_account_summary, product_search_catalog처럼 허용된 쿼리를 서버 코드 안에 고정한다.

데이터베이스 Tool은 SQL 실행기가 아니다

데이터베이스를 MCP에 붙일 때 가장 위험한 이름은 run_sql이다. 읽기 전용이라고 해도 자유 SQL은 스키마 탐색, 대량 조회, 개인정보 노출, 비싼 쿼리 실행으로 이어질 수 있다.

실무형 DB Tool의 기본값은 다음 네 가지다.

  1. 읽기 전용 DB 계정을 사용한다.

  2. Tool별로 허용된 쿼리만 실행한다.

  3. 모든 입력은 파라미터 바인딩으로 넣는다.

  4. 결과 행 수와 실행 시간을 서버에서 제한한다.

예를 들어 CRM 고객 조회 Tool은 다음처럼 만든다.

const CUSTOMER_LOOKUP_SQL = `
  SELECT id, email, name, plan, created_at
  FROM customers
  WHERE email = $1
  LIMIT 5
`;

server.registerTool(
  "crm_lookup_customer_by_email",
  {
    title: "CRM 고객 이메일 조회",
    description:
      "CRM에서 고객 이메일로 기본 정보를 조회합니다. 읽기 전용이며 결제 정보와 민감 정보는 반환하지 않습니다.",
    inputSchema: {
      email: z.string().email(),
    },
  },
  async ({ email }) => {
    const client = await db.connect();

    try {
      await client.query("BEGIN");
      await client.query("SET LOCAL statement_timeout = '3s'");

      const { rows } = await client.query(CUSTOMER_LOOKUP_SQL, [email]);

      await client.query("COMMIT");

      return {
        content: [
          {
            type: "text",
            text: rows.length
              ? `고객 ${rows.length}건을 찾았습니다.`
              : "일치하는 고객을 찾지 못했습니다.",
          },
        ],
        structuredContent: {
          rows,
          returned: rows.length,
          maxRows: 5,
        },
      };
    } catch (error) {
      await client.query("ROLLBACK").catch(() => undefined);

      return {
        isError: true,
        content: [{ type: "text", text: "CRM 조회 중 오류가 발생했습니다." }],
        structuredContent: {
          errorCode: "CRM_QUERY_FAILED",
        },
      };
    } finally {
      client.release();
    }
  }
);

이 예제는 단순하지만 실무에서 중요한 결정을 담고 있다. 결제 정보, 주소, 전화번호 같은 필드는 애초에 SELECT하지 않는다. 업무에 꼭 필요한 기본 식별 정보만 돌려주고, LIMIT는 모델의 요청이 아니라 서버의 정책으로 둔다. statement_timeout은 실수로 무거운 쿼리가 실행되는 상황을 막는다. 특히 커넥션 풀을 쓴다면 SET statement_timeout을 아무 연결에나 실행하지 말고, 같은 연결과 트랜잭션 안에서 SET LOCAL을 쓰거나 DB 계정·역할 수준에서 제한을 거는 편이 안전하다.

분석용 Tool도 같은 방식으로 만든다. "지난달 유료 전환율을 계산해 줘"라는 요청을 자유 SQL로 바꾸는 대신, analytics_get_conversion_summary 같은 Tool을 제공한다. 입력은 startDate, endDate, segment 정도로 제한한다. 쿼리는 서버에 고정한다. 모델은 질문을 Tool 입력으로 바꿀 뿐, SQL 작성자가 되지 않는다.

큰 응답은 잘라서 돌려준다

LLM은 긴 응답에 약하다. API나 DB가 5,000건을 반환할 수 있어도 모델에게 그대로 넘기면 비용이 늘고, 중요한 정보가 묻히고, 답변이 부정확해진다.

대용량 응답에는 세 가지 패턴을 쓴다.

첫째, 검색 Tool과 상세 조회 Tool을 분리한다.

  • docs_search_articles: 제목, 요약, ID만 반환

  • docs_get_article_detail: 선택한 문서 하나의 본문 반환

둘째, 페이지네이션을 Tool 입력과 출력에 넣는다.

inputSchema: {
  query: z.string().min(2).max(100),
  cursor: z.string().optional(),
  limit: z.number().int().min(1).max(50).default(10),
}

출력에는 hasMore, nextCursor, returned, totalMatching 같은 메타데이터를 포함한다. 커서는 클라이언트가 해석하지 못하는 불투명한 문자열로 만든다. 모델이 커서를 조작해 다른 범위를 읽게 두지 않기 위해서다. MCP 자체에는 도구 목록 같은 프로토콜 목록 페이지네이션이 있지만, Tool 결과 페이지네이션은 서버가 별도로 설계해야 한다. 실무에서는 limit 상한, nextCursor, truncated 여부를 명시하는 패턴이 많이 쓰인다.3

셋째, 전체 결과가 필요하면 본문 대신 Resource 링크를 돌려준다. 예를 들어 20MB CSV 보고서를 Tool 결과 텍스트에 넣지 않는다. Tool은 짧은 미리보기와 report://exports/2026-07-19-sales 같은 URI를 반환하고, 실제 내용은 Resource로 읽게 한다. MCP SDK는 Tool뿐 아니라 Prompt와 Resource도 등록할 수 있으므로, 큰 파일이나 설정 문서처럼 읽기 중심의 데이터는 Resource로 분리하는 편이 자연스럽다.2

파일 첨부도 같은 원칙을 따른다. 이메일 첨부, 로그 파일, PDF 계약서를 한 번에 모델에게 넘기지 않는다.

  • 파일명

  • MIME 타입

  • 크기

  • 해시

  • 생성 시각

  • 짧은 미리보기

  • 별도 Resource URI

이 정도만 먼저 반환한다. 사용자가 특정 파일을 고르면 files_read_attachment 같은 Tool이나 Resource 읽기로 본문을 가져온다. 민감 정보가 섞일 가능성이 있으면 서버에서 마스킹한 미리보기를 기본값으로 삼는다.

도메인 Prompt로 Tool 호출 품질을 높인다

Tool만 잘 만들어도 모델은 사용할 수 있다. 하지만 팀 업무에는 순서가 있다. 장애 대응, 고객 환불, 보안 이슈 검토, 릴리스 노트 작성은 각자 확인해야 할 항목과 금지해야 할 행동이 다르다.

이때 Prompt를 함께 제공하면 좋다. Prompt는 반복 업무를 시작하는 표준 양식이다.

예를 들어 장애 대응 서버라면 다음 Prompt를 제공할 수 있다.

server.registerPrompt(
  "incident_triage",
  {
    title: "장애 초기 분류",
    description:
      "장애 ID를 기준으로 최근 배포, 알림, 관련 로그를 확인하고 초기 보고서 초안을 작성합니다.",
    argsSchema: {
      incidentId: z.string().min(3),
    },
  },
  ({ incidentId }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `
장애 ${incidentId}를 초기 분류해 주세요.
다음 순서를 따르세요.
1. ops_get_incident_detail로 장애 기본 정보를 확인합니다.
2. ops_list_recent_deploys로 관련 배포를 확인합니다.
3. ops_search_logs는 최근 30분 범위로만 사용합니다.
4. 원인 단정은 피하고, 확인된 사실과 추정을 분리합니다.
5. 상태 변경이나 외부 공지는 수행하지 말고 초안만 작성합니다.
          `.trim(),
        },
      },
    ],
  })
);

Prompt는 보안 장치가 아니다. 모델에게 좋은 작업 순서를 알려 주는 안내문이다. 실제 제한은 Tool 스키마, 서버 권한, 승인 절차, 로그로 구현해야 한다. 그래도 Prompt는 도구 호출 품질을 크게 높인다. 모델이 어떤 Tool을 먼저 써야 하는지, 어느 범위까지 조회해야 하는지, 어떤 표현을 피해야 하는지 알 수 있기 때문이다.

여러 Tool은 하나의 업무 흐름처럼 설계한다

실무 서버가 커질수록 Tool 이름이 중요해진다. 이름이 제각각이면 모델도 사람도 헷갈린다. 같은 도메인 안에서는 동사를 일관되게 쓰는 편이 좋다.

권장 패턴은 다음과 같다.

동사의미예시
search조건으로 여러 항목 찾기crm_search_accounts
getID로 단일 항목 읽기crm_get_account_detail
list정해진 범위의 목록 보기ops_list_recent_deploys
create_draft초안 생성, 외부 영향 없음support_create_reply_draft
submit실제 제출 또는 전송support_submit_reply
approve승인 상태 변경finance_approve_refund

설명문도 일관되게 쓴다.

  • "읽기 전용입니다."

  • "최대 20건을 반환합니다."

  • "외부로 메시지를 보내지 않습니다."

  • "실행 전 사용자 승인이 필요합니다."

  • "민감 필드는 반환하지 않습니다."

이런 문장은 사람에게만 좋은 것이 아니다. 모델이 Tool을 고를 때도 중요한 힌트가 된다. 특히 create_draft와 submit을 분리하면 안전성이 크게 올라간다. 초안 생성은 자주 자동화해도 되지만, 제출은 사용자 확인이나 승인 토큰을 요구할 수 있다.

좋은 흐름은 다음처럼 읽힌다.

  1. support_search_tickets로 대상 티켓을 찾는다.

  2. support_get_ticket_detail로 상세 내용을 확인한다.

  3. support_create_reply_draft로 답변 초안을 만든다.

  4. 사용자가 확인한다.

  5. support_submit_reply로 전송한다.

이름만 봐도 업무 순서가 보이면 잘 설계된 서버다.

실무형 MCP 서버 체크리스트

실무 서버로 확장하기 전 다음 항목을 점검하자.

  • 외부 API를 범용 호출 Tool로 노출하지 않았는가?

  • REST와 GraphQL 호출은 업무 단위 Tool로 감쌌는가?

  • 재시도는 429, 5xx, 네트워크 오류처럼 재시도 가능한 상황에만 적용했는가?

  • 쓰기 API에는 멱등성 키, 미리보기, 사용자 승인 중 하나 이상이 있는가?

  • DB 계정은 읽기 전용인가?

  • 자유 SQL 대신 화이트리스트 쿼리를 사용하는가?

  • 결과 행 수, 실행 시간, 날짜 범위에 서버 측 제한이 있는가?

  • 대용량 응답은 요약, 페이지네이션, Resource 링크로 나누었는가?

  • 파일 첨부는 메타데이터와 미리보기를 먼저 반환하는가?

  • 도메인 Prompt가 Tool 사용 순서와 금지 행동을 안내하는가?

  • Tool 이름과 설명이 같은 규칙을 따르는가?

  • 모든 Tool 호출에 감사 로그가 남는가?

장난감 서버의 목표는 MCP가 어떻게 움직이는지 이해하는 것이다. 실무 서버의 목표는 팀이 안심하고 반복해서 쓰는 것이다. 기능을 늘리는 속도보다 경계를 선명하게 만드는 속도가 중요하다. API, DB, 사내 시스템을 연결할수록 Tool은 더 작고 명확해야 한다. 그래야 모델도 덜 헤매고, 사람도 결과를 검증할 수 있다.

참고

1Tools - Model Context Protocol

2Server | MCP TypeScript SDK (v1)

3MCP Pagination Patterns: Handling Large Result Sets Without Blowing Your Context

책 목록

MCP: AI 에이전트 연결의 표준

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기