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_docs | query, start, limit | 검색 결과 (제목, snippet, href, readTime) |
read_apic_doc | href | 해당 페이지 본문 Markdown |
get_apic_toc | section(선택) | 전체 또는 특정 섹션 목차 트리 |
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-mcp | API Connect 12.1.0 |
wxo-docs-mcp | watsonx Orchestrate |
concert-docs-mcp | IBM Concert |
instana-docs-mcp | Instana Observability |
iwhi-docs-mcp | IBM webMethods Hybrid Integration |
코드의 90% 이상이 공유 가능했고, 차이는 PRODUCT_KEY와 도구의 자연어 설명문 뿐이었습니다.
5. 한계
이 MCP가 의존하는 /docs/api/v1은 IBM이 공개적으로 안내한 적 없는 내부 API입니다. SLA가 없고, 스키마가 사전 공지 없이 바뀔 수 있고, 어느 날 엔드포인트가 사라져도 이상할 게 없습니다.
마치며
봇 차단처럼 보였던 화면 뒤에는 SPA가 호출하는 데이터 레이어가 있었고, 결국 필요했던 건 그 레이어를 똑같이 호출하는 단순한 코드였습니다. 모던 문서 사이트는 점점 더 SPA로 옮겨가고 있고, 에이전트에게 무언가를 읽히고 싶을 때 HTML 파싱과 싸우기 전에 그 페이지를 채우는 데이터 소스가 무엇인지 먼저 들여다보는 것이 종종 가장 짧은 길이 됩니다.
부록: 기술 스택
| 컴포넌트 | 역할 |
|---|---|
| TypeScript + Node.js | 런타임. ESM 빌드 후 stdio로 실행. |
@modelcontextprotocol/sdk | MCP 서버 구현 (McpServer + StdioServerTransport). |
zod | 도구 인자 스키마 정의 및 런타임 검증. |
jsdom | 본문 영역(main / article / .body) 추출 및 노이즈 태그 제거. |
turndown | HTML → Markdown 변환. 노이즈 차단, 인라인 코드, 표 보존은 커스텀 룰. |
IBM Docs Internal API (ibm.com/docs/api/v1) | 검색, 목차, 본문 데이터 소스. 브라우저 UA로 위장한 fetch로 호출. |