내 패턴 · 도구 시스템: MCP 브리지와 도구 정의·노출

한 줄 요약

모델이 쓰는 “도구”는 하네스 내장 도구 + MCP 서버가 주입하는 도구 두 층이고, MCP 서버는 {이름·설명·입력형식} 세트로 도구를 모델 앞에 차려준다. 왜 배우나: 내 도구 수십 개가 어디서 어떻게 모델에게 보이는지 알아야 새 도구를 붙이거나 도구가 안 보일 때 원인을 짚을 수 있다.

그림

flowchart TD
  A[세션 시작] --> B[.mcp.json 읽기]
  B --> C[node mcp-server.cjs 를 별도 프로세스로 띄움]
  C --> D["모델: 도구 목록 줘 ListTools 요청"]
  D --> E["서버: 메뉴판 생성 buildListToolsResponse<br/>이름·설명·입력형식"]
  E --> F[모델 화면에 mcp__...t__이름 으로 등록]
  F --> G{"설명서를 지금 다 줄까?"}
  G -->|상시 도구| H[모델이 바로 인자 채워 호출]
  G -->|이름만 먼저 deferred| I[ToolSearch 로 설명서 끌어오기]
  I --> H
  H --> J["CallTool: 도구의 handler 실제 실행"]
  J --> K["결과 글자 content text 를 모델에 반환"]

쉽게 풀기

식당 비유로 풀어 봅니다.

1) 메뉴판이 두 종류로 깔린다 — 모델은 손님이고, 앞에 메뉴판 두 장이 놓입니다.

  • 기본 메뉴: 식당이 원래 가진 내장 도구(Bash·Read·Edit). 항상 있고 설치 불필요.
  • 종업원이 가져다주는 메뉴: MCP 서버가 주입하는 도구. MCP(Model Context Protocol)는 “도구를 외부 프로그램으로 떼어내 표준 약속으로 연결하는” 방식.

2) 종업원은 한 명, 메뉴는 수십 개.mcp.jsont라는 종업원(MCP 서버) 딱 한 명만 부르는데, 이 한 명이 가져오는 메뉴판엔 LSP·AST·상태·메모·위키 버튼이 60개쯤 박혀 있습니다. 종업원 수 ≠ 메뉴 수.

3) 메뉴 한 줄 = 3종 세트 {이름, 설명, 입력형식}.

  • 이름: 무슨 버튼인지 (예: lsp_hover)
  • 설명: “언제·왜 누르는지” 안내문 — 모델은 이 글만 보고 판단
  • 입력형식: “어떤 값을 넣는지”(파일 경로·줄 번호 등)와 제약

4) 모델은 설명만 보고 손을 뻗는다 — 코드 내부는 못 봅니다. 설명·입력형식만 읽고 호출을 정하면, 값 검사·파일 읽기·에러 포장은 전부 종업원 쪽 handler가 하고 모델엔 결과 글자만 돌아옵니다.

flowchart LR
  M[모델] -->|"설명·입력형식만 읽음"| D[도구 정의]
  M -->|값 채워 호출| H[handler 실행함수]
  H -->|"값검사·파일읽기·에러포장"| W[실제 작업]
  W -->|결과 글자만| M

5) 메뉴 많으면 이름만 먼저(deferred) — 버튼 60개 설명서를 항상 펼치면 컨텍스트를 너무 먹습니다. 그래서 일부는 이름만 먼저 깔고, 모델이 ToolSearch로 요청해야 입력형식이 펼쳐집니다. “메뉴판엔 이름만, 누르려면 그 버튼 설명서를 먼저 끌어오기.”

핵심 정리

개념한마디로어디에
2계층 도구내장 + MCP 주입내장은 접두사 없음
MCP 서버 t종업원 한 명.mcp.json
도구 3종 세트이름·설명·입력형식서버 내부 정의
deferred 노출이름만 먼저, 설명은 나중ToolSearch로 로드

실제 예시

(1) MCP 서버 등록 — 종업원 한 명 부르기

// /home/seunghyeong/.claude/plugins/marketplaces/omc/.mcp.json
{ "mcpServers": { "t": {
  "command": "node",
  "args": ["${CLAUDE_PLUGIN_ROOT}/bridge/mcp-server.cjs"]
} } }

command=실행 바이너리(node), args=인자. ${CLAUDE_PLUGIN_ROOT}는 플러그인 루트로 치환되는 변수.

(2) 도구 정의 한 줄 — 이름·설명·입력형식·실행

// bridge/mcp-server.cjs
var lspHoverTool = {
  name: "lsp_hover",
  description: "Get type information, documentation, and signature at a specific position in a file. ...",
  schema: {
    file: external_exports.string().describe("Path to the source file"),
    line: external_exports.number().int().min(1).describe("Line number (1-indexed)"),
    character: external_exports.number().int().min(0).describe("Character position (0-indexed)")
  },
  handler: async ({ file, line, character }) => withLspClient(file, "hover", async (client) =>
    formatHover(await client.hover(file, line - 1, character)))
};

부작용(상태 쓰기)이 있는 도구는 annotations로 위험도를 표기합니다.

(3) 메뉴판 만들기 + 호출 받기 (zod → JSONSchema)

서버는 zod schema를 MCP 표준 JSONSchema(inputSchema)로 바꿔 노출합니다. 변환기는 ZodOptional을 벗기고, ZodDefaultdefault를 채우며, int 체크가 있으면 integer로, 옵셔널 아닌 키는 required에 넣습니다. 두 흐름(목록 요청 / 호출 요청)이 한 서버에서 갈립니다.

flowchart TD
  R1[ListTools 요청] --> B1[buildListToolsResponse]
  B1 --> Z[zodToJsonSchema 변환]
  Z --> O1["name·description·inputSchema 목록 반환"]
  R2["CallTool 요청 name+args"] --> F2[allTools 에서 name 으로 찾기]
  F2 -->|없음| E2[Unknown tool isError]
  F2 -->|있음| H2[tool.handler 실행]
  H2 --> O2["content·isError 반환"]
// bridge/mcp-server.cjs
function buildListToolsResponse() {
  return { tools: allTools.map((tool) => ({
    name: tool.name, description: tool.description,
    inputSchema: zodToJsonSchema2(tool.schema),
    ...tool.annotations ? { annotations: tool.annotations } : {}
  })) };
}
var server = new Server({ name: "t", version: "1.0.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => buildListToolsResponse());
setStandaloneCallToolRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  const tool = allTools.find((t) => t.name === name);
  if (!tool) return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
  const result = await tool.handler(args ?? {});
  return { content: result.content, isError: result.isError ?? false };
});

allTools는 카테고리 배열을 펼쳐 합친 단일 레지스트리 — 이 한 배열이 곧 메뉴판 전체입니다.

(4) 두 번째 서버 team — JSONSchema 손으로 적기 (현재 DEPRECATED)

bridge/team-mcp.cjs는 두 번째 MCP 서버(name:"team")로, zod 대신 JSONSchema를 직접 적습니다. codex/gemini 외부 CLI 워커를 tmux로 돌리던 도구들이며, 지금은 [DEPRECATED]로 CLI(omc team ...) 이관 안내만 합니다. 실제 워커 본체는 bridge/team-bridge.cjsspawnCliProcess(provider, ...)가 provider별로 codex exec ... 또는 gemini ...를 자식 프로세스로 띄웁니다(기본 gpt-5.3-codex / gemini-3.1-pro-preview).

(5) 직접 만들 때 최소 골격

직접 만들 때 체크리스트:

  • .mcp.json에 서버 등록 (command/args, 경로는 ${CLAUDE_PLUGIN_ROOT} 권장)
  • 도구마다 name·description(언제 쓸지+예시)·schema·handler 4종 채움
  • 모든 인자에 .describe() — 모델은 이 문구로 인자를 채운다
  • 옵셔널은 .optional()·.default(), 정수는 .int() → JSONSchema required·type가 정확해짐
  • handler는 항상 {content:[{type:"text",text}], isError?} 반환
  • 부작용 도구엔 annotations.destructiveHint 등으로 위험도 표기
  • stdio로 connect, 로그는 stderr(console.error)로만 — stdout은 프로토콜 채널이라 오염 금지
  • 도구 많으면 deferred 노출 + ToolSearch 지연 로드로 컨텍스트 절약
  • 내장 도구(Bash/Read/Edit)와 역할 겹치지 않게 — MCP는 “내장으로 못 하는 것”(LSP/AST/상태/위키) 담당

요약 & 셀프체크

3줄 요약:

  1. 모델 도구는 내장 + MCP 주입 2계층이고, MCP 도구는 mcp__...t__이름 접두사로 구분된다.
  2. 도구 한 줄은 {이름·설명·입력형식·실행함수}, 모델은 설명·입력형식만 읽고 호출하며 실제 일은 서버 handler가 한다.
  3. 도구가 많으면 이름만 먼저 노출(deferred)하고 ToolSearch로 설명서를 지연 로드해 컨텍스트를 아낀다.

스스로 답해 보기:

  • 모델은 도구의 실제 코드를 보나요, 아니면 무엇만 보고 호출을 정하나요?
  • 도구 이름 앞 접두사(mcp__...t__)는 왜 붙고, 내장 도구와는 어떻게 구분되나요?
  • 새 MCP 도구를 만들 때 인자마다 .describe()를 꼭 달아야 하는 이유는?

연결

MINE_개요 · _분석축_루브릭