내 패턴 · 도구 시스템: 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.json은 t라는 종업원(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을 벗기고, ZodDefault는 default를 채우며, 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.cjs의 spawnCliProcess(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·handler4종 채움 - 모든 인자에
.describe()— 모델은 이 문구로 인자를 채운다 - 옵셔널은
.optional()·.default(), 정수는.int()→ JSONSchemarequired·type가 정확해짐 -
handler는 항상{content:[{type:"text",text}], isError?}반환 - 부작용 도구엔
annotations.destructiveHint등으로 위험도 표기 - stdio로
connect, 로그는 stderr(console.error)로만 — stdout은 프로토콜 채널이라 오염 금지 - 도구 많으면 deferred 노출 +
ToolSearch지연 로드로 컨텍스트 절약 - 내장 도구(Bash/Read/Edit)와 역할 겹치지 않게 — MCP는 “내장으로 못 하는 것”(LSP/AST/상태/위키) 담당
요약 & 셀프체크
3줄 요약:
- 모델 도구는 내장 + MCP 주입 2계층이고, MCP 도구는
mcp__...t__이름접두사로 구분된다. - 도구 한 줄은
{이름·설명·입력형식·실행함수}, 모델은 설명·입력형식만 읽고 호출하며 실제 일은 서버handler가 한다. - 도구가 많으면 이름만 먼저 노출(deferred)하고
ToolSearch로 설명서를 지연 로드해 컨텍스트를 아낀다.
스스로 답해 보기:
- 모델은 도구의 실제 코드를 보나요, 아니면 무엇만 보고 호출을 정하나요?
- 도구 이름 앞 접두사(
mcp__...t__)는 왜 붙고, 내장 도구와는 어떻게 구분되나요? - 새 MCP 도구를 만들 때 인자마다
.describe()를 꼭 달아야 하는 이유는?