웹 도구를 의존성 없는 MCP 서버로 전환하기
요약
기존의 웹 분석 도구인 DomainIntel을 Model Context Protocol(MCP) 서버로 전환하여 AI 에이전트가 직접 사용할 수 있도록 구현하는 과정을 다룹니다. esbuild를 사용하여 의존성 없는 단일 npx 실행 파일로 번들링할 때 발생하는 ESM 전환 및 Node 내장 모듈 관련 기술적 문제를 해결하는 방법을 설명합니다.
핵심 포인트
- MCP를 통해 기존 분석 도구를 AI 에이전트용 도구(tools)로 쉽게 노출 가능
- npx 실행을 위해 esbuild를 활용한 단일 파일 번들링 전략 사용
- CommonJS 모듈을 ESM으로 전환할 때 정적 임포트 방식의 필요성
- ESM 환경에서 Node 내장 모듈(net 등)의 동적 require 문제 해결
저는 WHOIS, DNS, SSL/TLS, HTTP 보안 헤더, 차단 목록 평판(blocklist reputation), 그리고 서브도메인 탐색(subdomain discovery) 등 모든 도메인을 분석하는 작은 웹 앱인 DomainIntel을 운영하고 있습니다. 분석 엔진은 이미 Express API 뒤에서 Node 모듈 세트로 존재하고 있었습니다. 문제는 어떻게 하면 새로운 서비스를 통째로 구축하지 않고도 AI 에이전트가 이를 직접 사용하게 할 수 있을까 하는 점이었습니다.
그 해답은 Model Context Protocol (MCP)였습니다. 이 포스트는 제가 이를 npx -y @domainintel/mcp — 즉, 런타임 의존성이 없는 단일 파일로 어떻게 출시했는지에 대한 실무적이고 주의사항이 가득한 버전입니다.
구조 (The shape of it)
MCP 서버는 에이전트가 호출할 수 있는 "도구 (tools)"를 노출합니다. 저는 각 분석기를 하나의 도구로 래핑(wrap)했습니다: whois_lookup, dns_records, ssl_certificate, security_headers, domain_reputation, subdomain_discovery, 그리고 모든 것을 실행하고 종합 점수를 반환하는 full_domain_report가 그것입니다. 각 도구는 domain을 인자로 받아 구조화된 JSON을 반환합니다. 전체 서버는 기존 분석기들과 MCP SDK의 stdio 전송(transport) 계층 위에 약 150줄 정도의 코드로 구성됩니다.
흥미로운 점은 MCP 코드가 아니었습니다. 바로 패키징(packaging)이었습니다.
목표: 설치 마찰이 없는 npx 구현
저는 사용자가 npx -y @domainintel/mcp를 실행하기만 하면, 클론(clone)이나 의존성 트리의 npm install 없이도 바로 작동하기를 원했습니다. 이는 서버와 서버가 재사용하는 분석기 그래프(analyzer graph)를 하나의 독립된 파일로 번들링(bundling)해야 함을 의미합니다. 저는 esbuild를 사용했습니다. 네 가지 문제가 저를 괴롭혔는데, 이들은 모두 "CommonJS 코드를 재사용하는 Node CLI를 ESM 바이너리로 번들링하기"라는 일반적인 문제들이었습니다.
1. 번들러가 임포트(import)를 추적할 수 있도록 하는 createRequire
제 서버는 원래 createRequire를 사용하여 분석기들을 가져왔습니다:
const require = createRequire(import.meta.url);
const { analyzeDns } = require('../lib/analyzers/dns');
esbuild는 런타임 createRequire 호출을 추적할 수 없습니다. esbuild는 정적 임포트(static imports)만 볼 수 있기 때문입니다. 그래서 아무것도 번들링되지 않았습니다. 해결책은 정적 기본 임포트(static default imports)로 전환하는 것이었으며, 이는 Node의 ESM 로더가 CommonJS 모듈의 module.exports로 매핑합니다.
import dnsPkg from '../lib/analyzers/dns.js';
const { analyzeDns } = dnsPkg;
이제 esbuild가 그래프를 따라가며, node server.mjs는 여전히 개발 환경에서 작동합니다.
2. ESM 출력에서의 Node 내장 모듈: "'net'의 동적 require는 지원되지 않습니다" (Dynamic require of 'net' is not supported)
한 의존성(whois)이 내부적으로 require('net')을 호출합니다. esbuild의 ESM 출력에는 require가 없기 때문에 런타임에 오류를 발생시킵니다. 해결책은 createRequire를 통해 이를 정의하는 배너(banner)를 추가하는 것입니다. 이 배너는 esbuild의 shim이 내장 모듈용으로 사용합니다:
banner: {
js: [
'#!/usr/bin/env node',
...
3. stdout은 프로토콜에 속하므로 여기에 로깅하지 마세요 (stdout belongs to the protocol — don't log to it)
MCP over stdio는 JSON-RPC 스트림을 위해 stdout을 사용합니다. 제가 만든 분석기들의 공유 로거(shared logger)는 logs/ 디렉토리에 쓰도록 되어 있었는데, 이는 전역으로 설치되는 CLI에게도 잘못된 방식입니다 (npm install 디렉토리 내부에 mkdir을 시도할 것입니다). 저는 빌드 시간에 이를 esbuild의 onResolve 플러그인을 사용하여 stderr 전용 stub으로 교체했습니다. 따라서 실제 애플리케이션은 파일 로깅을 유지하고 번들(bundle)은 stdout에서 조용히 작동합니다:
build.onResolve({ filter: /utils[\/]errorLogger(\.js)?$/ }, () => ({ path: stub }));
4. 사소한 부분 (The small stuff)
- 두 개의 shebang. 진입 파일에
#!/usr/bin/env node가 있었고, 추가된 배너에도 하나가 있어 번들의 2번째 줄이 유효하지 않은#이 되었습니다. 소스에서 제거했습니다. "type": "module"+ CommonJS. 분석기들을 `
그러면 당신의 에이전트는 dig/whois를 실행하여 텍스트를 파싱하는 대신, _"stripe.com에 대한 전체 보고서를 작성해줘"_라고 명령하여 구조화된 (structured) 결과를 얻을 수 있습니다.
사용 가능한 핵심 로직 (core)을 가진 도구를 유지 관리하고 있다면, 이를 MCP 서버로 래핑(wrapping)하는 것은 적은 노력으로 가능하며, 단일 파일로 번들링(bundling)하면 진정으로 단 한 번의 명령만으로 도입할 수 있습니다. 소스 코드는 GitHub에서 확인할 수 있으며, 위의 내용에 대해 궁금한 점이 있다면 기꺼이 답변해 드리겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기