기존 API 앞에 MCP 서버 세우기

작성일:2026.06.14|수정일:2026.06.14|조회수:3

기존 API 앞에 MCP 서버 세우기

AI 에이전트에게 API 문서를 건네는 것과 도구를 건네는 것은 다르다. 사람은 문서를 읽고 endpoint를 고르고, 필요한 header와 body를 맞춰서 요청을 만든다. 모델도 어느 정도는 그렇게 할 수 있지만, 매번 그렇게 시키면 작업이 API 사용법 설명으로 흘러간다. 자동화를 하려던 건데, 어느 순간 사람은 모델에게 “그 endpoint는 이 body로 호출해야 하고, 실패하면 이렇게 처리해야 해”라고 계속 말하고 있다.

MCP 서버는 이 반복을 줄이는 경계다. 서버는 자신이 제공하는 tool 목록을 client에게 알려주고, client는 그 목록을 바탕으로 모델에게 사용할 수 있는 행동을 노출한다. 모델은 더 이상 임의의 HTTP endpoint를 조합하는 것이 아니라, 이름과 설명과 입력 schema를 가진 tool을 호출한다. 이 차이는 작아 보이지만, 실제 자동화에서는 꽤 크다. API를 직접 호출하는 모델은 문서를 기억해야 하지만, MCP tool을 호출하는 모델은 서버가 제공한 도구 표면을 따라가면 된다.

MCP 서버를 만든다는 것은 그래서 새 백엔드를 세우는 일과 조금 다르다. 이미 동작하는 API나 service layer가 있다면, MCP 서버는 그 앞에 서는 adapter가 될 수 있다. 기존 API는 계속 검증과 권한 확인과 side effect를 담당하고, MCP 서버는 그 기능들을 모델이 이해할 수 있는 tool로 번역한다. 문이 새로 생겼다고 집 안의 배관까지 다시 깔 필요는 없다. 문은 어디로 열리는지만 분명하면 된다.

먼저 서버의 정체성을 정한다

공식 TypeScript SDK 기준으로 MCP 서버의 시작점은 McpServer다. 여기서 서버 이름과 버전을 정한다.

TS
const server = new McpServer({
  name: "my-service",
  version: "1.0.0",
});

이 정보는 단순한 장식이 아니다. MCP client가 initialize 요청을 보내면 서버는 protocol version, capabilities, serverInfo 같은 정보를 돌려준다. client는 이 응답을 보고 서버가 어떤 기능을 제공하는지 이해한다. 사람에게는 이름과 버전이 별것 아닌 것처럼 보여도, protocol 관점에서는 “이 서버가 누구이고 무엇을 할 수 있는가”를 알리는 첫 응답이다.

이 단계에서 너무 많은 것을 넣으려고 할 필요는 없다. 서버의 identity는 작게 시작해도 된다. 중요한 것은 여기서부터 client와 server 사이의 계약이 시작된다는 점이다. MCP 서버는 그냥 HTTP endpoint가 아니라, 초기화 과정을 거쳐 capabilities를 드러내는 protocol participant가 된다.

Tool은 서버의 공개 표면이다

서버의 정체성을 정했다면 다음은 tool이다. MCP 서버에서 tool은 모델이 호출할 수 있는 행동의 단위다. 이름이 있고, 설명이 있고, 입력 schema가 있고, 호출되었을 때 실행되는 handler가 있다.

TS
server.registerTool(
  "publishPost",
  {
    description: "Publish a blog post",
    inputSchema: z.object({ slug: z.string() }),
  },
  async ({ slug }) => publishPost(slug)
);

작은 서버라면 이렇게 tool을 직접 등록해도 충분하다. 단일 목적의 local tool, 간단한 script wrapper, 실험용 MCP 서버라면 오히려 이 편이 읽기 쉽다. tool이 몇 개 없고, 정의가 다른 곳에 중복되지 않는다면 직접 등록은 좋은 출발점이다.

하지만 기존 API 위에 MCP 서버를 세우는 경우에는 한 가지를 먼저 봐야 한다. 이미 tool에 해당하는 operation 목록이 어딘가에 존재하는가. operation 이름, 설명, 입력값, 위험도 같은 정보가 기존 backend에 있다면 MCP 서버가 같은 정의를 다시 들고 있을 필요가 없다. 같은 정의가 두 곳에 생기면 처음에는 편하지만, 나중에는 둘 중 하나가 낡는다. 이런 중복은 대체로 조용히 낡는다. 컴파일 에러도 안 나고, 배포도 잘 된다. 대신 어느 날 모델이 사라진 인자를 들고 와서 사람을 깨운다.

기존 API가 manifest를 제공할 수 있다면, MCP 서버는 그 manifest를 읽어 tool을 만들 수 있다.

TXT
기존 API
  ↓ operation manifest
MCP server
  ↓ tools/list
MCP client

이 구조에서는 tool 정의의 source of truth가 기존 API에 남는다. MCP 서버는 그 정의를 protocol에 맞게 바꿔서 드러낸다. 물론 manifest가 빈약하면 tool도 빈약해진다. 모델에게 좋은 tool을 주려면 operation 이름만으로는 부족하다. 설명, 입력값, destructive 여부, dry-run 가능 여부 같은 정보가 같이 있어야 한다. 결국 좋은 MCP 서버는 좋은 내부 manifest를 요구한다.

수동 JSON-RPC 구현은 출발점이 될 수 있다

MCP는 JSON-RPC 기반 요청을 주고받는다. 그래서 처음에는 필요한 method만 직접 처리하는 방식으로 시작할 수 있다.

TXT
initialize  → 서버 정보와 capabilities 반환
tools/list  → tool 목록 반환
tools/call  → tool 실행
ping        → 빈 응답

이 방식은 실험 단계에서 빠르다. request body를 읽고, method를 확인하고, 직접 JSON-RPC 응답을 만들면 된다. 당장 연결하려는 client가 요구하는 흐름만 맞추면 어느 정도 동작한다. 작은 개인 프로젝트에서는 이런 구현이 꽤 오래 버티기도 한다.

하지만 원격 MCP 서버로 계속 사용할 생각이라면 금방 질문이 늘어난다.

TXT
notification은 어떤 status로 끝내야 하지?
batch request는 배열 응답인가, 단일 응답인가?
tool 실패는 JSON-RPC error인가, CallToolResult의 isError인가?
Streamable HTTP에서 GET과 DELETE는 열어야 하나?
client가 Accept header를 어떻게 보내야 하지?
session id가 있으면 이후 요청에서 무엇을 확인해야 하지?

수동 구현은 내가 아는 만큼만 정확하다. 처음에는 필요한 부분만 구현하니 작고 편하지만, 시간이 지나면 내 해석이 protocol처럼 굳는다. 테스트도 그 해석을 기준으로 작성된다. 어느 순간부터는 실제 MCP 동작을 검증하는 것이 아니라, 내가 만든 MCP 흉내가 계속 같은 흉내를 내는지 확인하게 된다. 흉내도 꾸준하면 제법 그럴듯하다. 그래서 더 위험하다.

공식 SDK는 protocol의 무게를 덜어준다

공식 SDK를 쓰면 JSON-RPC message shape와 기본 request handling을 직접 붙잡고 있을 필요가 줄어든다. McpServer에 tool을 등록하고, transport를 연결하고, 들어오는 HTTP 요청을 SDK transport에 넘기는 형태가 된다.

TS
const server = new McpServer({
  name: "my-service",
  version: "1.0.0",
});

server.registerTool(
  "someTool",
  {
    description: "Do something useful",
    inputSchema: z.object({ id: z.string() }),
  },
  async ({ id }) => {
    return {
      content: [{ type: "text", text: `handled ${id}` }],
    };
  }
);

SDK를 붙인다고 설계가 사라지는 것은 아니다. 오히려 설계해야 할 부분이 더 선명해진다. 서버 이름은 무엇인가. 어떤 tool을 공개할 것인가. schema는 어디서 올 것인가. tool handler는 실제 작업을 직접 할 것인가, 기존 API로 위임할 것인가. protocol의 세부 처리에서 손을 조금 떼면, 애플리케이션 경계에 더 집중할 수 있다.

tool result도 MCP의 형태를 따른다. 성공하면 content를 돌려주고, 구조화된 응답이 필요하면 structuredContent를 함께 줄 수 있다. 실패는 아무렇게나 던지는 것이 아니라, client가 이해할 수 있는 error 형태로 정리되어야 한다. SDK는 일반적인 handler error를 tool error result로 감싸는 흐름을 제공하지만, 어떤 실패를 protocol error로 볼지, 어떤 실패를 tool execution error로 볼지는 서버를 만드는 쪽에서 의도를 가져야 한다.

원격 서버라면 transport가 설계의 일부가 된다

local MCP server라면 stdio 기반으로 시작하는 경우가 많다. 하지만 원격 서버라면 HTTP transport를 고민해야 한다. 현재 원격 MCP 서버를 만들 때는 Streamable HTTP가 중심이 된다. 하나의 endpoint에서 POST, GET, DELETE 같은 method가 각자 역할을 갖는다.

TXT
POST    → JSON-RPC message 전송
GET     → SSE stream 열기
DELETE  → session 종료
OPTIONS → CORS preflight

여기서 흔한 실수는 MCP endpoint를 평범한 REST endpoint처럼 보는 것이다. REST 감각으로 보면 GET과 DELETE를 막고 POST만 열고 싶어진다. 그런데 Streamable HTTP transport에서는 client가 GET으로 stream을 열 수 있고, DELETE로 session 종료를 요청할 수 있다. 서버가 session을 쓰지 않는 stateless 구조라면 모든 기능을 다 적극적으로 사용할 필요는 없지만, method를 어떻게 다룰지는 client의 기대와 맞춰야 한다.

Next.js route에서는 SDK의 WebStandardStreamableHTTPServerTransport를 붙일 수 있다.

TS
const transport = new WebStandardStreamableHTTPServerTransport({
  enableJsonResponse: true,
  sessionIdGenerator: undefined,
});

await server.connect(transport);

const response = await transport.handleRequest(request, {
  parsedBody,
});

enableJsonResponse: true는 JSON response를 기대하는 client나 테스트 흐름에 맞다. sessionIdGenerator를 두지 않으면 server-managed session을 만들지 않는 쪽에 가깝다. 요청마다 인증 정보를 확인하고 처리하는 간단한 원격 endpoint라면 이런 stateless 형태가 더 자연스러울 수 있다.

또 하나 놓치기 쉬운 것은 Accept header다. Streamable HTTP client는 application/jsontext/event-stream을 모두 받을 수 있어야 한다. 서버 쪽에서 SDK transport에 넘기기 전에 이 header가 없다면 보정해주는 것도 실전에서는 도움이 된다.

TS
function withSdkAcceptHeader(request: Request, parsedBody?: unknown) {
  const headers = new Headers(request.headers);

  if (!headers.has("Accept")) {
    headers.set("Accept", "application/json, text/event-stream");
  }

  return new Request(request.url, {
    method: request.method,
    headers,
    body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
  });
}

이런 코드는 화려하지 않지만 연결 문제를 줄여준다. protocol 구현에서 가장 피곤한 버그는 대체로 대단한 알고리즘이 아니라 header 하나, method 하나, content type 하나에서 나온다. 컴퓨터는 사소한 것에 예민하다. 사람도 그렇지만 컴퓨터는 사과를 받아주지 않는다.

client 연결까지 확인해야 서버가 완성된다

route test가 통과해도 MCP 서버가 실제로 쓸 수 있다는 뜻은 아니다. MCP 서버는 client와 붙어서 tool 목록을 제공하고, tool call을 받아 실행할 때 의미가 있다. 그래서 마지막에는 실제 client에서 연결되는지 확인해야 한다.

opencode 같은 client에서는 remote MCP server를 config에 등록한다.

JSON
{
  "mcp": {
    "my-service": {
      "type": "remote",
      "url": "https://example.com/api/mcp",
      "enabled": true,
      "oauth": false,
      "headers": {
        "Authorization": "Bearer {env:MY_SERVICE_TOKEN}"
      }
    }
  }
}

여기서도 MCP 서버 만들기와 인증 설계가 바로 만난다. remote MCP는 외부 client가 호출하는 endpoint다. tool이 단순 조회만 한다면 위험이 작을 수 있지만, 상태를 바꾸는 tool이라면 인증 없이 열 수 없다. config에 token을 평문으로 넣는 것도 피해야 한다. client 연결을 확인하는 일은 단순히 “connected가 뜨는가”가 아니라, 올바른 인증 방식으로 연결되는가까지 포함한다.

연결 확인은 이런 식의 결과로 끝난다.

TXT
✓ my-service connected
https://example.com/api/mcp

이 한 줄이 나오기까지는 꽤 많은 경로가 이어진다.

TXT
MCP client config
  ↓ URL / headers / oauth 설정
HTTP transport
  ↓ initialize / tools/list / tools/call
MCP server
  ↓ tool handler
기존 API 또는 service

어딘가 하나만 어긋나도 연결은 실패하거나, 더 나쁘게는 연결은 되는데 tool call이 이상하게 실패한다. 그래서 MCP 서버를 만들 때는 route 단위 테스트와 실제 client 연결 확인을 둘 다 해야 한다.

기존 API 앞에 세우는 adapter 구조

이제 기존 API가 있는 경우로 내려오면, 구조는 비교적 분명해진다.

TXT
MCP client
  ↓ tools/list
/api/mcp
  ↓ operation manifest 조회
/api/blog-operations

MCP client
  ↓ tools/call
/api/mcp
  ↓ operation 실행 위임
/api/blog-operations

블로그 관리 API에는 글 발행, 태그 병합, 카테고리 변경, 시리즈 정렬, 캐시 갱신 같은 operation이 있었다. 이 operation들은 이미 기존 backend에서 실행되고 있었고, 권한 확인도 그쪽에 있었다. MCP 서버가 새로 맡아야 할 일은 operation manifest를 tool 목록으로 바꾸고, tool call을 기존 API의 request shape로 변환하는 것이었다.

실제 tool 등록은 manifest를 기반으로 했다.

TS
const operations = await readOperationManifest();

for (const [name, operation] of Object.entries(operations)) {
  server.registerTool(
    name,
    {
      description: `${operation.description}${operation.destructive ? " Destructive operation." : ""}`,
      inputSchema: fromJsonSchema({
        type: "object",
        additionalProperties: true,
        description:
          operation.params.length > 0
            ? `Parameters: ${operation.params.join(", ")}`
            : "No parameters required.",
      }),
      annotations: {
        destructiveHint: operation.destructive,
        readOnlyHint: false,
      },
    },
    (toolArguments) => callBlogTool(request, name, toolArguments)
  );
}

여기서 destructiveHint 같은 annotation은 작지만 중요하다. 모든 tool이 같은 위험도를 갖지 않는다. 캐시 갱신과 글 삭제는 다르다. 태그 추천 조회와 orphan tag 삭제도 다르다. 모델에게 tool을 줄 때는 “무엇을 할 수 있는가”뿐 아니라 “얼마나 조심해야 하는가”도 같이 전달해야 한다.

실행은 기존 경로로 돌려보낸다

tool handler는 DB를 직접 만지지 않고 기존 API로 요청을 만든다.

TS
async function callBlogTool(
  request: Request,
  name: string,
  toolArguments: Record<string, unknown>
) {
  const headers = new Headers({ "Content-Type": "application/json" });
  const authorization = request.headers.get("Authorization");
  const cookie = request.headers.get("Cookie");

  if (authorization) {
    headers.set("Authorization", authorization);
  }

  if (cookie) {
    headers.set("Cookie", cookie);
  }

  const operationRequest = new Request(request.url, {
    method: "POST",
    headers,
    body: JSON.stringify({ operation: name, params: toolArguments }),
  });

  const operationResponse = await postBlogOperation(operationRequest);
  const operationBody = await operationResponse.json();

  if (!operationResponse.ok) {
    throw new ProtocolError(-32000, "Blog operation failed", {
      status: operationResponse.status,
      body: operationBody,
    });
  }

  return {
    content: [
      {
        type: "text" as const,
        text: JSON.stringify(operationBody, null, 2),
      },
    ],
    structuredContent: operationBody,
  };
}

외부 request의 header를 그대로 복사하지 않고 필요한 것만 넘겼다. AuthorizationCookie는 인증 문맥을 유지하기 위해 필요하고, Content-Type은 내부 요청 body를 위해 필요하다. 나머지 header까지 무심코 넘기면 내부 handler가 의도하지 않은 정보를 받게 될 수 있다. adapter는 얇아야 하지만, 아무거나 통과시키는 파이프가 되면 곤란하다.

이 구조의 장점은 기존 backend의 책임을 유지한다는 점이다. operation validation은 /api/blog-operations가 한다. 권한 확인도 그쪽에서 다시 한다. cache revalidation이나 DB update도 원래 있던 경로를 탄다. MCP 서버는 실행 경로를 새로 만들지 않고, 기존 경로로 들어가는 protocol adapter로 남는다.

테스트는 두 층으로 나눠서 본다

공식 SDK로 옮기면서 테스트도 바뀌었다. 수동 JSON-RPC 구현에서는 내가 직접 만든 응답 shape를 검증했다. SDK로 옮긴 뒤에는 SDK behavior에 맞춰 기대값을 바꿔야 했다.

예를 들어 GET과 DELETE는 더 이상 단순히 405로 막는 대상이 아니었다. Streamable HTTP transport로 들어가는 method가 됐다. tool 실행 실패도 내가 만든 JSON-RPC error 객체가 아니라 SDK가 표현하는 tool error result에 가까워졌다. batch request나 initialized notification 처리도 SDK 흐름에 맞춰 다시 확인해야 했다.

검증은 두 층으로 나눴다.

TXT
protocol layer
  - initialize 응답
  - tools/list
  - tools/call
  - batch request
  - GET/DELETE/OPTIONS method

application layer
  - 인증 실패
  - 권한 부족
  - blog operation 실패
  - request size limit
  - batch size limit

실제로는 다음 정도를 확인했다.

TXT
- /api/mcp route test 통과
- /api/blog-operations auth/permission test 통과
- 전체 test 통과
- lint 통과
- build 통과
- opencode mcp list에서 connected 확인

route test가 통과한다고 실제 client 연결이 보장되지는 않는다. 반대로 client가 connected라고 해서 내부 권한 경계가 맞다는 뜻도 아니다. MCP 서버는 protocol 경계와 application 경계가 겹치는 곳에 있기 때문에, 둘 다 봐야 한다.

인증은 다음 경계로 이어진다

MCP 서버가 외부 client의 입구가 되는 순간 인증 문제도 같이 온다. 단순 조회 tool만 있다면 위험이 작을 수 있지만, 글 발행, 삭제, bulk update, cache invalidation처럼 상태를 바꾸는 tool이라면 인증을 나중에 붙이는 장식처럼 다룰 수 없다.

adapter 구조에서는 인증도 두 경계로 나눌 수 있다.

TXT
/api/mcp
  - MCP endpoint에 들어올 수 있는 actor인지 확인

/api/blog-operations
  - 실제 operation을 실행해도 되는지 다시 확인
  - publish/delete/uploadImages 같은 세부 권한 확인

입구에서 한 번 보고, 실제 실행 지점에서 다시 본다. 중복처럼 보일 수 있지만, 외부 client가 들어오는 endpoint에서는 이 정도 중복이 오히려 안전하다. MCP 서버가 tool 목록을 잘못 열어도, 마지막 실행 지점에서 다시 막을 수 있다.

여기서 별도의 문제가 이어진다. remote MCP client가 인증 header를 보내야 한다면 token은 어디에 저장해야 할까. config에 그대로 넣으면 편하지만, 그 편함은 나중에 대체로 찝찝함으로 돌아온다. 이전에 정리했던 AI 에이전트의 비밀값을 macOS Keychain에 맡기기는 여기서 다시 등장한다. MCP 서버를 세우는 것과 secret을 전달하는 것은 다른 층위의 작업이지만, 실제 연결에서는 붙어 있다.

MCP 서버는 새 기능보다 새 연결 방식이다

MCP 서버를 만들면서 가장 분명해진 것은 역할의 크기였다. 기존 API나 service layer가 있는 애플리케이션에서 MCP 서버는 모든 일을 새로 구현하는 곳이 아니다. 모델이 이해할 수 있는 tool 경계를 만들고, protocol 처리는 SDK에 맡기고, 실제 실행은 이미 검증된 경로로 돌려보내는 쪽이 오래 간다.

그 순서는 대략 이렇다.

TXT
1. 서버 이름과 capabilities를 정한다.
2. tool 정의의 source of truth를 정한다.
3. tool을 SDK에 등록한다.
4. 원격 서버라면 Streamable HTTP transport를 붙인다.
5. 실제 MCP client에서 연결을 확인한다.
6. tool handler는 기존 실행 경로로 위임한다.
7. 인증은 입구와 실행 지점에서 나눠 확인한다.

이렇게 두면 MCP 서버는 작게 남는다. 작다는 것은 덜 중요하다는 뜻이 아니다. 외부 client와 내부 시스템 사이의 경계가 작고 분명하다는 뜻이다. 모델에게 도구를 열어주는 일은 생각보다 위험한 일이다. 그래서 이 경계는 많은 일을 하기보다, 정확히 필요한 일만 하는 편이 좋다.

만들고 보니 MCP 서버를 세운다기보다 기존 시스템 앞에 모델이 읽을 수 있는 문을 하나 단 느낌에 가까웠다. 문은 얇아도 된다. 대신 어디로 열리는지는 분명해야 한다.

댓글

댓글을 불러오는 중...