지식위키

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

내 패턴 · 도구 시스템: 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로 로드

[!note]- 펼쳐보기: 모델에게 도구가 보이는 방식 + 정의 객체 전체 필드 모델 화면에서:

  • mcp__plugin_oh-my-claudecode_t__<이름> 처럼 긴 접두사가 붙음 → “어느 MCP 서버 소속인지”를 박아 이름 충돌 방지.
  • 내장 도구(Bash/Read/Edit)는 접두사 없음 — 이 유무가 곧 2계층을 가르는 표식.
  • 모델이 읽는 것은 description(언제 쓸지)과 inputSchema(인자 필수·타입) 둘뿐.

도구 정의 객체 필드 (서버 내부, zod 스키마):

  • name (필수): 식별자(소문자_스네이크 권장)
  • description (필수): 모델이 언제 쓸지 판단하는 자연어 설명. 예시까지 길게 적기도 함
  • schema (필수): 인자별 zod 타입 맵. .describe()로 인자 설명, .optional()·.int()·.min()으로 제약
  • handler (필수): 실행 함수. {content:[{type:"text",text}], isError?} 반환
  • annotations (선택): readOnlyHint·destructiveHint·idempotentHint·openWorldHint 위험도 힌트

실제 예시

(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로 위험도를 표기합니다.

[!note]- 펼쳐보기: annotations 붙은 도구 전문 (state_write)

// bridge/mcp-server.cjs
var ... = {
  name: "state_write",
  description: "Write/update state for a specific mode. ...",
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  schema: {
    mode: external_exports.enum(STATE_TOOL_MODES).describe("The mode to write state for"),
    active: external_exports.boolean().optional().describe("Whether the mode is currently active"),
    iteration: external_exports.number().optional().describe("Current iteration number"),
    state: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe("Additional custom state fields ..."),
    session_id: external_exports.string().optional().describe("Session ID for session-scoped state isolation. ...")
  },
  handler: async (args) => { /* ... */ }
};

(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는 카테고리 배열을 펼쳐 합친 단일 레지스트리 — 이 한 배열이 곧 메뉴판 전체입니다.

[!note]- 펼쳐보기: allTools 레지스트리 전체 구성

var allTools = [
  ...lspTools,        // lsp_hover, lsp_goto_definition, lsp_find_references, ...
  ...astTools,        // ast_grep_search, ast_grep_replace
  pythonReplTool,     // python_repl
  ...stateTools,      // state_read/write/clear/list_active/get_status
  ...notepadTools,    // notepad_read/write_*/prune/stats
  ...memoryTools,     // project_memory_read/write/add_note/add_directive
  ...traceTools,      // trace_summary/timeline
  ...sharedMemoryTools,
  deepinitManifestTool,
  ...wikiTools,       // wiki_query/add/read/list/ingest/lint/delete
  ...skillsTools      // list_omc_skills, load_omc_skills_local/global
];

(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).

[!note]- 펼쳐보기: 손으로 적은 JSONSchema 전문 (omc_run_team_start)

// bridge/team-mcp.cjs
var TOOLS = [{
  name: "omc_run_team_start",
  description: "[DEPRECATED] CLI-only migration required. ...",
  inputSchema: {
    type: "object",
    properties: {
      teamName: { type: "string", description: "Slug name for the team" },
      agentTypes: { type: "array", items: { type: "string" }, description: '"claude", "codex", or "gemini" per worker' },
      tasks: { type: "array", items: { type: "object", properties: {
        subject: { type: "string" }, description: { type: "string" } }, required: ["subject","description"] } },
      cwd: { type: "string", description: "Working directory (absolute path)" }
    },
    required: ["teamName", "agentTypes", "tasks", "cwd"]
  }
}, /* omc_run_team_status / _wait / _cleanup */ ];

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

[!note]- 펼쳐보기: 단일 서버 최소 골격 전문 (my-mcp-server.cjs + .mcp.json)

// my-mcp-server.cjs — 단일 서버가 여러 도구를 노출하는 최소 골격
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const { ListToolsRequestSchema, CallToolRequestSchema } = require("@modelcontextprotocol/sdk/types.js");
const { z } = require("zod");

// 1) 도구 = {name, description, schema(zod), handler}
const echoTool = {
  name: "echo",
  description: "Echo text back. Use to verify the tool channel works.",
  schema: { text: z.string().describe("Text to echo") },
  handler: async ({ text }) => ({ content: [{ type: "text", text: `echo: ${text}` }] })
};
const allTools = [echoTool /* , ...더 */];

// 2) zod → JSONSchema (옵셔널 벗기기 + required 계산)
function toJsonSchema(shape) {
  const properties = {}, required = [];
  for (const [k, v] of Object.entries(shape)) {
    properties[k] = { type: "string", description: v._def?.description };
    if (!(v.isOptional && v.isOptional())) required.push(k);
  }
  return { type: "object", properties, required };
}

// 3) 서버 등록
const server = new Server({ name: "myt", version: "1.0.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: allTools.map(t => ({ name: t.name, description: t.description, inputSchema: toJsonSchema(t.schema) }))
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const tool = allTools.find(t => t.name === req.params.name);
  if (!tool) return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
  try { return await tool.handler(req.params.arguments ?? {}); }
  catch (e) { return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; }
});
(async () => { await server.connect(new StdioServerTransport()); })();
// .mcp.json — 위 서버를 한 줄로 등록
{ "mcpServers": { "myt": { "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/my-mcp-server.cjs"] } } }

직접 만들 때 체크리스트:

  • .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_개요 · _분석축_루브릭