사내 위키 MCP 서버를 반나절에 띄웠는데, 정작 코드는 가장 쉬운 부분이었다
정유나
@yuna_ai
사내 위키와 이슈 트래커를 Claude에 붙이려고 MCP 서버를 직접 짰다. 처음엔 벡터DB 박고 RAG 파이프라인 깔아야 하나 싶어서 며칠 잡을 각오를 했는데, 막상 해보니 @modelcontextprotocol/sdk 깔고 tool 3개 정의하니 끝이었다. 반나절 걸렸다. 정작 시간을 잡아먹은 건 코드가 아니라 다른 데였다.
tool 정의 자체는 REST 핸들러랑 다를 게 없다
tool은 사실상 함수 시그니처 + 설명 + 핸들러다. searchWiki(query), getPage(id), searchIssues(keyword) 세 개를 정의했는데, inputSchema를 JSON Schema로 적고 핸들러에서 기존 내부 REST API를 그대로 호출했다. 평소 컨트롤러 짜던 거랑 구조가 똑같다.
server.tool(
"search_wiki",
"사내 위키 문서를 키워드로 검색한다. 키워드는 명사 위주로 1~3개. 이슈 번호(#1234)나 자연어 문장을 그대로 넣지 말 것.",
{ query: z.string() },
async ({ query }) => {
const res = await wikiClient.search(query);
return { content: [{ type: "text", text: JSON.stringify(res) }] };
}
);
진짜 난관은 description이었다
처음엔 description에 위키 문서 검색이라고만 적었다. 그랬더니 모델이 이슈 번호(#1234)를 위키 검색에 그대로 꽂거나, 사용자 질문 문장을 통째로 쿼리로 넘겨서 검색 결과가 0건으로 나왔다. tool을 잘못 부르는 게 아니라 인자를 엉뚱하게 채우는 문제였다.
위 코드처럼 키워드는 명사 위주로, 이슈 번호는 search_issues로까지 description에 박고 나서야 모델이 분기를 제대로 탔다. tool이 여러 개일 때는 각 description에 "이건 언제 쓰고, 저건 언제 쓰지 말라"를 명시하는 게 정확도에 직결됐다. 결국 코드 30줄보다 description 문장 다듬는 데 더 오래 걸렸다.
stdio 서버에서 console.log 금지
이건 한 번 데여야 안다. stdio transport는 stdout이 JSON-RPC 채널이다. 디버깅하려고 핸들러에 console.log 한 줄 찍었더니 클라이언트 쪽에서 파싱이 깨지면서 연결이 끊겼다. 로그는 전부 console.error(stderr)로 빼야 한다.
// console.log("search:", query) // 이거 한 줄에 JSON-RPC 깨짐
console.error("[wiki] search:", query) // stderr로
정리
MCP 서버는 "무거운 인프라"가 아니라 "기존 API에 description 잘 붙인 얇은 어댑터"에 가까웠다. 코드 난이도는 낮고, 품질을 가르는 건 모델이 이 tool을 언제·어떻게 부를지 description으로 설계하는 부분이다. RAG부터 떠올렸던 게 무색할 만큼, 읽기 전용 조회 도구라면 tool 몇 개 노출하는 것으로 충분했다.