에이전트에게 도구를 붙일 때 가장 먼저 생기는 질문은 “무엇을 연결할 것인가”가 아니다. 더 앞에는 “어디까지를 하나의 경계로 볼 것인가”가 있다.
이번 실험의 출발점은 간단했다. GitHub 저장소를 읽고 쓰는 도구, 로컬 Obsidian 볼트를 검색하고 읽고 쓰는 도구, NotebookLM 노트북을 조회하고 질문하는 도구를 하나의 MCP 서버로 묶는다. 다만 서버를 로컬에만 두지 않고, Cloudflare Worker를 얇은 공개 입구로 세운다.
구조는 다음과 같다.
1 | Agent |
핵심은 Worker가 “모든 것을 직접 아는 서버”가 아니라, 세 종류의 도구 호출을 JSON-RPC 모양으로 받아 각각의 안전한 하위 경계로 넘기는 얇은 라우터라는 점이다.
공개 엔드포인트를 숨기는 방식
MCP 엔드포인트는 /mcp/<MCP_PATH_TOKEN> 경로에서만 열린다. 토큰이 없거나 너무 짧으면 아예 닫힌다.
1 | function isMcpPath(pathname: string, env: Env): boolean { |
여기서 safeEqual은 문자열 길이와 글자 비교에서 생길 수 있는 타이밍 차이를 줄이기 위한 상수 시간 비교 함수다. 완전한 인증 체계의 대체물은 아니지만, 공개 URL에 비밀 경로를 둔다면 최소한 비교 방식부터 느슨하게 만들지 않는 편이 낫다.
이 경계는 다음 원칙을 따른다.
- 토큰이 설정되지 않으면 MCP는 열리지 않는다.
- 헬스체크는
/와/health에서만 단순 상태를 돌려준다. - 실제 도구 호출은
POST /mcp/<secret-token>로만 들어온다. - 민감한 API 키는 코드가 아니라 Worker 환경 변수로만 주입된다.
MCP의 최소 핸드셰이크
에이전트가 처음 연결하면 Worker는 initialize 요청에 답한다.
1 | if (body.method === "initialize") { |
이 구현은 리소스, 프롬프트, 세션 상태를 넓게 열지 않는다. 우선 도구 목록과 도구 호출만 제공한다. 실험 단계에서는 이 정도의 좁은 표면이 좋다. 연결할 수 있는 것은 분명하지만, 아직 확장하지 않은 것은 노출하지 않는다.
도구 목록은 세 묶음으로 나뉜다
tools/list는 세 종류의 도구를 반환한다.
첫 번째는 GitHub 도구다.
list_repositoriesget_file_contentscreate_or_update_file
두 번째는 Obsidian 도구다.
obsidian_searchobsidian_read_noteobsidian_write_note
세 번째는 NotebookLM 도구다.
nlm_list_notebooksnlm_query_notebooknlm_add_source
도구 이름은 일부러 평범하게 두었다. 에이전트가 사용할 이름은 멋진 내부 추상화보다 “무엇을 하는가”가 바로 보여야 한다. 특히 MCP 도구는 사람이 직접 부르는 함수가 아니라 모델이 선택하는 인터페이스이므로, 이름과 설명이 곧 사용성이다.
GitHub는 원격 저장소 경계
GitHub 호출은 REST API 헬퍼 하나로 모은다.
1 | async function callGitHubApi<T>( |
읽기 도구는 GitHub Contents API의 base64 응답을 디코딩한다. 쓰기 도구는 sha를 받으면 기존 파일 업데이트가 되고, 없으면 새 파일 생성이 된다. 이 차이를 도구 스키마에 그대로 드러내면 에이전트가 “먼저 읽어서 sha를 확보한 뒤 업데이트한다”는 순서를 배울 수 있다.
Obsidian은 로컬 지식 경계
Obsidian Local REST API는 보통 로컬 네트워크 안에 있다. Worker에서 직접 접근하려면 터널 주소가 필요하다. 그래서 OBSIDIAN_TUNNEL_URL과 OBSIDIAN_API_KEY를 환경 변수로 받는다.
1 | async function callObsidianApi( |
여기서 중요한 것은 볼트 전체를 추상화하지 않는다는 점이다. 검색, 읽기, 쓰기라는 가장 작은 세 동작만 연다. 에이전트가 로컬 지식베이스를 다룰 수 있게 하되, 경로와 본문은 호출마다 명시하게 한다.
NotebookLM은 브리지 경계
NotebookLM은 GitHub처럼 단순 REST API로만 끝나지 않는다. 그래서 Worker는 로컬 nlm_bridge.py에 /call 요청을 보내고, 브리지가 실제 NotebookLM 조작을 담당한다.
1 | async function callNlmBridge(payload: Record<string, unknown>, env: Env): Promise<string> { |
이 부분은 MCP 서버 안에 브라우저 자동화나 NotebookLM 세션 처리를 밀어 넣지 않기 위한 분리다. Worker는 공개 입구이고, 브리지는 로컬에서 더 무거운 조작을 맡는다. 실패도 이 경계에서 명확해진다. Worker는 “브리지 호출 실패”를 보고하고, 브리지는 NotebookLM 쪽의 실제 문제를 다룬다.
도구 서버를 만들 때 남는 기준
이 코드는 크지 않지만, 에이전트 도구 서버를 만들 때 꽤 좋은 기준을 남긴다.
첫째, 공개 경계는 작아야 한다. /mcp/<secret-token> 하나만 열고, 그 안에서도 initialize, tools/list, tools/call만 처리한다.
둘째, 비밀은 코드에 쓰지 않는다. GITHUB_TOKEN, OBSIDIAN_API_KEY, NLM_BRIDGE_SECRET, MCP_PATH_TOKEN은 모두 환경 변수다.
셋째, 도구는 에이전트가 이해할 수 있는 이름을 가져야 한다. 내부 구현이 GitHub Contents API든 Obsidian Local REST API든 NotebookLM 브리지든, 모델에게 보이는 표면은 명확한 동사와 객체여야 한다.
넷째, 로컬과 원격의 경계를 섞지 않는다. GitHub는 원격 API, Obsidian은 터널로 노출된 로컬 볼트, NotebookLM은 로컬 브리지다. 이 세 경계를 한 파일 안에 두더라도, 헬퍼와 도구 단위는 분리되어야 한다.
MCP 서버는 거창한 플랫폼으로 시작할 필요가 없다. 오히려 처음에는 작은 라우터가 낫다. 에이전트가 쓸 수 있는 도구를 세심하게 이름 붙이고, 비밀과 권한의 경계를 닫아두고, 실패가 어디서 났는지 보이게 만드는 것. 그 정도만 잘해도 로컬 지식, 원격 저장소, 연구 노트북이 하나의 작업면 위로 올라온다.
댓글
GitHub 계정으로 의견을 남길 수 있습니다. 댓글은 GitHub Discussions에 저장됩니다.