Aiden Kwak Pages
03 · Project

IBM Docs MCP 서버 구축기

봇 접근이 차단된 IBM 공식 문서 사이트를 우회해, AI 에이전트가 검색, 조회할 수 있는 MCP 서버를 만든 과정.

2026-05-06 ·

Repo ↗

IBM에서 일을 하다 보면 공식 문서를 자주 들춰봐야 합니다. API Connect, Watson Orchestrate, Concert, Instana 같은 제품들은 문서가 방대하고, 그 중 어디에 답이 있는지 찾는 데만 한참이 걸립니다. 사람이 일일이 읽고 짜집기 해야 하는 그 일을 Claude Code, Claude Desktop, IBM Bob 같은 에이전트에게 넘길 수 있다면 작업 속도가 크게 달라집니다.

문제는 IBM의 공식 문서 사이트(ibm.com/docs)가 봇 접근을 차단하고 있다는 점이었습니다. 일반적인 fetch 요청으로 페이지를 받으면 본문이 들어 있지 않은 빈 HTML이 돌아옵니다. 그래서 그 제약을 우회해 IBM API Connect 12.1.0 문서를 검색, 조회할 수 있는 MCP 서버를 만들었습니다.

저는 이 과정에서 아래 네 가지를 차례로 풀어야 했습니다.

  • 문서 사이트가 왜 봇 접근에 닫혀 있는가, 그리고 우회 경로는 무엇인가
  • 발견한 내부 API를 어떻게 호출할 것인가
  • HTML 본문을 LLM이 효율적으로 읽을 수 있는 형태로 어떻게 변환할 것인가
  • 이걸 어떻게 MCP 도구로 노출하고, 다른 IBM 제품 문서로 확장할 것인가

1. 문서 사이트가 왜 봇 접근에 닫혀 있는가

문제: 직접 fetch로는 본문이 오지 않는다

ibm.com/docs/SSMNED_12.1.x_cd/...html 같은 문서 URL을 fetch로 호출하면 응답에는 페이지 골격만 있고, 정작 우리가 읽고 싶은 콘텐츠는 들어 있지 않습니다.

원인은 두 가지가 겹쳐 있습니다. 첫째, 이 사이트는 SPA입니다. HTML 응답 안에 본문이 임베드되어 있지 않고, 클라이언트 측 JS가 별도의 데이터를 받아 화면을 채웁니다. 둘째, 기본 fetch User-Agent 는 봇으로 분류되어 일부 응답이 필터링됩니다. 두 제약이 합쳐져 "봇 차단"처럼 보이는 결과가 나옵니다.

결정: 사이트가 아닌, SPA가 호출하는 데이터 레이어를 본다

브라우저로는 콘텐츠가 정상적으로 보이니, 화면을 채우는 데이터 소스가 어딘가에 있다는 뜻입니다. 크롬 개발자 도구의 네트워크 탭을 열고 한 페이지를 새로고침하면 다음과 같은 요청이 보입니다.

GET /docs/api/v1/search?query=...&products=SSMNED_12.1.x_cd
GET /docs/api/v1/toc/SSMNED_12.1.x_cd
GET /docs/api/v1/content/{href}?parsebody=true

ibm.com/docs/api/v1. 프론트엔드가 화면을 채우기 위해 호출하는 내부 JSON API입니다. 검색, 목차, 본문 - 우리가 필요한 모든 게 여기에 모여 있습니다. SPA의 본질은 클라이언트가 데이터 레이어를 호출해 그리는 구조이므로, 우리도 같은 레이어를 직접 호출하면 됩니다.

2. 내부 API 호출

문제: 엔드포인트를 알아도 기본 fetch UA로는 막힌다

엔드포인트를 알아도 fetch 기본 헤더로 요청을 보내면 응답이 비어 오거나 거부됩니다. 봇 필터가 페이지뿐 아니라 API 레이어에도 걸려 있기 때문입니다. 다만 토큰이나 쿠키 기반 인증은 요구하지 않습니다.

결정: 일반 브라우저 UA로 위장한 단순 fetch

const PRODUCT_KEY = "SSMNED_12.1.x_cd";
const BASE_URL = "https://www.ibm.com/docs";
const API_BASE = `${BASE_URL}/api/v1`;
const USER_AGENT =
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
  "AppleWebKit/537.36 (KHTML, like Gecko) " +
  "Chrome/120.0.0.0 Safari/537.36";

const headers = {
  "User-Agent": USER_AGENT,
  Accept: "application/json",
};

세 엔드포인트를 각각 함수로 감쌌습니다.

2-1. 검색

export async function searchDocs(query: string, start = 0, limit = 10) {
  const url =
    `${API_BASE}/search?query=${encodeURIComponent(query)}` +
    `&lang=en&start=${start}&limit=${limit}` +
    `&products=${PRODUCT_KEY}`;
  const res = await fetch(url, { headers });
  if (!res.ok) throw new Error(`Search failed: ${res.status}`);
  return res.json() as Promise<SearchResponse>;
}

응답은 hits(전체 매치 수), topics(결과 배열) 형태입니다. 각 topic에는 title, snippet, href, fullurl, readTime 같은 메타가 들어 있어 검색 결과 카드로 그대로 노출하기 좋습니다. 다만 title, snippet에는 검색어 강조용 <em> 태그가 섞여 들어오기 때문에, 노출 단계에서 stripHtmlTags로 정리합니다.

2-2. 목차

export async function fetchToc() {
  const url = `${API_BASE}/toc/${PRODUCT_KEY}?lang=en`;
  const res = await fetch(url, { headers });
  return res.json() as Promise<TocResponse>;
}

목차는 topicId, href, label, topics(자식 노드)로 구성된 트리입니다. 에이전트가 문서 전체 구조를 한 번에 파악하고 원하는 섹션으로 좁혀 들어갈 때 유용합니다. 섹션 라벨로 필터링해 일부만 반환하는 옵션도 도구 단에서 제공합니다.

2-3. 본문

export async function fetchDocContent(href: string) {
  const url = `${API_BASE}/content/${href}?parsebody=true&lang=en`;
  const res = await fetch(url, {
    headers: { ...headers, Accept: "text/html" },
  });
  return res.text();
}

본문 엔드포인트는 JSON이 아닌 HTML 조각을 돌려줍니다. parsebody=true를 붙이면 페이지 본문 영역만 추려진 형태로 와서 후처리 부담이 줄어듭니다.

3. HTML → Markdown 변환

문제: HTML을 그대로 LLM에 던질 이유가 없다

본문 응답은 HTML입니다. LLM이 HTML을 못 읽는 건 아니지만, 토큰 효율, 구조 보존, 가독성 모든 면에서 Markdown이 우위입니다. 게다가 HTML에는 <script>, <style>, <nav>, <footer> 같은 컨텍스트 오염원이 함께 따라옵니다.

결정: jsdom으로 본문 영역만 추출한 뒤 turndown으로 변환

import { JSDOM } from "jsdom";
import TurndownService from "turndown";

export function extractAndConvert(html: string): string {
  const dom = new JSDOM(html);
  const doc = dom.window.document;

  const main =
    doc.querySelector("main") ||
    doc.querySelector("article") ||
    doc.querySelector(".body") ||
    doc.querySelector("body");

  if (!main) return htmlToMarkdown(html);

  main
    .querySelectorAll("script, style, meta, nav, footer")
    .forEach((el) => el.remove());

  return htmlToMarkdown(main.innerHTML);
}

본문 영역을 main → article → .body → body 순으로 fallback하며 추출하고, 노이즈 태그를 제거한 뒤 Markdown으로 변환합니다. 그 위에 turndown 룰 세 개를 추가했습니다.

3-1. 노이즈 차단

jsdom 단계에서 한 번 제거했지만, fallback 경로(querySelector가 main을 못 찾을 때)를 위해 turndown 단에서도 안전망을 둡니다. script, style, meta, head, title, nav, footer는 변환기 입력으로 들어와도 빈 문자열로 치환됩니다.

3-2. 인라인 코드

turndown 기본 <code> 처리는 백틱 사이에 잉여 공백을 끼우는 등 사소한 노이즈를 만듭니다. 단순히 백틱 두 개로 감싸도록 룰을 덮어썼습니다.

3-3. 표 보존

IBM 문서는 표가 매우 많습니다. 설치 옵션, API 파라미터, 환경 변수 - 모두 표로 정리되어 있습니다. turndown은 기본으로 표를 지원하지 않아, 룰을 추가하지 않으면 표가 텍스트로 평탄화되어 행과 열의 의미가 사라집니다. 그래서 <table> 룰을 직접 작성해 헤더, 구분자, 바디 행을 Markdown 표 문법으로 직렬화합니다.

turndown.addRule("preserveTables", {
  filter: "table",
  replacement: (_content, node) => {
    const table = node as HTMLTableElement;
    const rows = Array.from(table.rows);
    if (rows.length === 0) return "";

    const headerCells = Array.from(rows[0].cells).map(
      (c) => c.textContent?.trim() || ""
    );
    const separator = headerCells.map(() => "---");
    const bodyRows = rows.slice(1).map((row) =>
      Array.from(row.cells).map((c) => c.textContent?.trim() || "")
    );

    const lines = [
      `| ${headerCells.join(" | ")} |`,
      `| ${separator.join(" | ")} |`,
      ...bodyRows.map((r) => `| ${r.join(" | ")} |`),
    ];
    return `\n${lines.join("\n")}\n`;
  },
});

이 룰의 유무는 검색 결과의 가독성과 LLM 활용도에 큰 차이를 만들었습니다. 특히 환경 변수표, 권한 매트릭스처럼 행, 열 의미가 강한 콘텐츠는 표 보존 없이는 사실상 활용이 어렵습니다.

4. MCP 서버로 노출

문제: 함수만으로는 에이전트가 호출할 수 없다

세 함수(searchDocs, fetchToc, fetchDocContent)만 있으면 코드 레벨에서는 충분하지만, AI 에이전트가 직접 호출할 표준 인터페이스가 없습니다. MCP(Model Context Protocol)는 정확히 이 간극을 메우는 표준입니다.

결정: @modelcontextprotocol/sdk로 세 도구를 stdio 서버로 노출

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "apic-docs", version: "1.0.0" });

server.tool(
  "search_apic_docs",
  "Search IBM API Connect 12.1.0 documentation. " +
    "Returns matching topics with titles, snippets, and URLs.",
  {
    query: z.string().describe("Search query"),
    start: z.number().optional().default(0),
    limit: z.number().optional().default(10),
  },
  async ({ query, start, limit }) => {
    const result = await searchDocs(query, start, Math.min(limit, 20));
    const formatted = result.topics.map((t) => ({
      title: stripHtmlTags(t.title),
      url: t.fullurl,
      snippet: stripHtmlTags(t.snippet),
      href: t.href,
      date: t.date,
      readTime: `${t.readTime}min`,
    }));
    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify(
            { totalHits: result.hits, results: formatted },
            null,
            2
          ),
        },
      ],
    };
  }
);

세 도구를 등록했습니다.

도구인자반환
search_apic_docsquery, start, limit검색 결과 (제목, snippet, href, readTime)
read_apic_dochref해당 페이지 본문 Markdown
get_apic_tocsection(선택)전체 또는 특정 섹션 목차 트리

stdio 전송으로 띄우면, MCP를 지원하는 어떤 클라이언트에서든 설정에 경로 한 줄만 추가해 사용할 수 있습니다.

{
  "mcpServers": {
    "apic-docs": {
      "command": "node",
      "args": ["/absolute/path/to/apic-docs-mcp/dist/index.js"]
    }
  }
}

4-1. 같은 패턴으로 다른 IBM 제품 문서까지 확장

IBM의 docs 제품들은 모두 동일한 ibm.com/docs/api/v1 위에 올라가 있습니다. PRODUCT_KEY와 도구 명만 바꾸면 같은 구조로 다른 제품 MCP를 만들 수 있습니다. 같은 베이스 코드로 Watson Orchestrate, Concert, Instana, IWHI용 MCP 서버까지 차례로 구축했습니다.

MCP대상 제품
apic-docs-mcpAPI Connect 12.1.0
wxo-docs-mcpwatsonx Orchestrate
concert-docs-mcpIBM Concert
instana-docs-mcpInstana Observability
iwhi-docs-mcpIBM webMethods Hybrid Integration

코드의 90% 이상이 공유 가능했고, 차이는 PRODUCT_KEY와 도구의 자연어 설명문 뿐이었습니다.

5. 한계

이 MCP가 의존하는 /docs/api/v1은 IBM이 공개적으로 안내한 적 없는 내부 API입니다. SLA가 없고, 스키마가 사전 공지 없이 바뀔 수 있고, 어느 날 엔드포인트가 사라져도 이상할 게 없습니다.

마치며

봇 차단처럼 보였던 화면 뒤에는 SPA가 호출하는 데이터 레이어가 있었고, 결국 필요했던 건 그 레이어를 똑같이 호출하는 단순한 코드였습니다. 모던 문서 사이트는 점점 더 SPA로 옮겨가고 있고, 에이전트에게 무언가를 읽히고 싶을 때 HTML 파싱과 싸우기 전에 그 페이지를 채우는 데이터 소스가 무엇인지 먼저 들여다보는 것이 종종 가장 짧은 길이 됩니다.

부록: 기술 스택

컴포넌트역할
TypeScript + Node.js런타임. ESM 빌드 후 stdio로 실행.
@modelcontextprotocol/sdkMCP 서버 구현 (McpServer + StdioServerTransport).
zod도구 인자 스키마 정의 및 런타임 검증.
jsdom본문 영역(main / article / .body) 추출 및 노이즈 태그 제거.
turndownHTML → Markdown 변환. 노이즈 차단, 인라인 코드, 표 보존은 커스텀 룰.
IBM Docs Internal API (ibm.com/docs/api/v1)검색, 목차, 본문 데이터 소스. 브라우저 UA로 위장한 fetch로 호출.
Back toProject 목록