OMC · MCP 도구 시스템 (단일 't' 서버 + tool-registry 노출)
OMC · MCP 도구 시스템 (단일 ‘t’ 서버 + tool-registry 노출)
한 줄 요약
OMC는 LSP·AST·파이썬·상태·메모·메모리·트레이스·위키·스킬 등 수십 개 도구를 종류별로 따로 등록하지 않고 단 하나의 MCP 서버 t 안에 몰아넣어 한꺼번에 펼쳐 보여준다.
→ 왜 배우나: 도구가 100개로 불어나도 등록 설정(.mcp.json)은 한 줄도 안 바뀌게 만드는 설계 패턴이라, 그대로 가져다 쓸 수 있다.
그림
flowchart TD
A[".mcp.json<br/>서버는 'tʼ 단 하나"] --> B["node bridge/mcp-server.cjs<br/>(esbuild 단일 번들)"]
B --> C["서버 기동<br/>이름=t · stdio 연결"]
C --> D["모델이 도구 목록 요청<br/>(ListTools)"]
D --> E["allTools 순회<br/>+ Zod→JSON Schema 변환"]
E --> F["모델 화면에 도구 진열<br/>mcp__..._t__state_read 등"]
F --> G["모델이 도구 호출<br/>(CallTool)"]
G --> H["이름으로 도구 찾아 handler 실행"]
H --> I["{ content:["{"type:text"}"] } 반환"]
I --> F
[!info] 그림 읽는 법 가게(서버)는 하나뿐인데 안에 “LSP 코너·파이썬 코너·메모 코너”가 진열대(도구)로 늘어선 구조다. 진열 목록의 단일 진실 원천이
allTools배열 하나이고, 도구를 늘려도 가게 간판(.mcp.json)은 영원히t한 글자다.
쉽게 풀기
도구를 “공구”라 부르고 따라가 보자.
-
공구함은 하나뿐 — 이름이
t인 이유 드라이버 가게·망치 가게를 따로 차리는(도구 종류마다 서버 등록) 대신, OMC는 공구함 하나에 다 넣는다. 이름이 한 글자t인 건 토큰 절약이다. 모델이 보는 도구 이름은 전부mcp__플러그인_t__도구이름꼴이라, 가게 이름이 길면 그 접두사가 도구 수만큼 반복돼 컨텍스트를 잡아먹는다. -
공구 규격표 —
ToolDef모든 공구는 같은 양식(“이름 / 설명 / 입력 규격 / 동작 함수”)을 채워야 한다. 이게ToolDef인터페이스다. 양식이 통일돼 있으니 서버는 공구가 100개여도 똑같이 다룬다. -
공구 목록표 —
allTools패밀리(state·notepad·lsp 등)별 묶음을 한 배열에...(spread)로 합친 게allTools다. 여기 적힌 순서가 곧 모델에 보이는 순서이고, 도구 추가는 결국 이 배열에 한 줄 끼우는 일이다. -
번역 — Zod → JSON Schema 입력 규격은 코드에선 Zod로 적지만 MCP 통신 규격은 JSON Schema라, 목록을 펼칠 때
zodToJsonSchema()가 통역한다. 이때.optional()을 안 붙인 칸은 자동으로 **필수(required)**가 된다 — 깜빡하면 선택 인자가 강제 인자로 둔갑한다. -
결과는 같은 봉투에 —
content[].text어떤 공구든 결과는{ content: [{ type: 'text', text: ... }] }봉투로 돌려준다. 실패 시isError: true도장을 함께 찍는다. 봉투가 통일돼 모델이 결과를 일관되게 읽는다.
flowchart LR F1["패밀리: lspTools"] --> AT["allTools 배열<br/>(단일 진실 원천)"] F2["패밀리: stateTools"] --> AT F3["패밀리: wikiTools ..."] --> AT AT -->|"순회 + zodToJsonSchema"| LT["ListTools 응답<br/>name·description·inputSchema"] LT --> M["모델이 보고 호출 결정"]
핵심 정리
ToolDef 양식의 칸(슬림 버전).
| 필드 | 필수 | 한 줄 역할 |
|---|---|---|
name | 도구 식별자. 노출 시 mcp__..._t__{name} | |
description | 모델이 “쓸까?” 판단할 때 읽는 설명문 | |
schema | Zod 입력 규격. 나갈 때 JSON Schema로 변환 | |
handler | 동작 함수. 항상 content[].text 봉투 반환 | |
annotations | 부수효과 의도 힌트(읽기전용·파괴·멱등·외부) |
[!note]- 펼쳐보기:
annotations4종 힌트 (도구 성격표)
readOnlyHint: true— 상태를 안 바꿈(읽기 전용). 클라이언트의 우선 로딩 판단에 사용.destructiveHint: true— 삭제 등 파괴 동작 가능(readOnly가 false일 때만 의미).idempotentHint: true— 여러 번 호출해도 안전(재시도 OK).openWorldHint: true— 네트워크 등 외부세계와 상호작용.- 예)
state_read=읽기전용·멱등,state_write=쓰기·멱등,state_clear=destructiveHint:true.
[!note]- 펼쳐보기: 등록부의 두 설계 결정 + 인터페이스 관계 (파일 주석 근거)
- AST 도구는 항상 배열에 존재한다.
@ast-grep/napi가 없으면 표면에서 빠지는 게 아니라 런타임에 “도움말 에러 메시지”를 반환하며 우아하게 degrade한다.- 팀 런타임 도구(
omc_run_team_start등)는 의도적으로 제외. 별도 MCP 서버team(bridge/team-mcp.cjs)에 산다.- 패밀리 파일은 제네릭 버전
ToolDefinition<T extends z.ZodRawShape>(src/tools/types.ts)로 작성하고, registry에서as unknown as ToolDef로 평탄화해 합친다. 두 인터페이스는name/description/annotations/schema/handler필드가 동일해 안전하게 섞인다.
실제 예시
(1) 서버는 단 하나, t
// .mcp.json
{
"mcpServers": {
"t": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/bridge/mcp-server.cjs"]
}
}
}
${CLAUDE_PLUGIN_ROOT}는 설치 위치로 치환된다. 실행 명령은 “노드로 번들 하나를 띄워라”가 전부다.
(2) 도구 1개의 규격 — ToolDef
// src/mcp/tool-registry.ts
export interface ToolDef {
name: string;
description: string;
annotations?: {
readOnlyHint?: boolean; destructiveHint?: boolean;
idempotentHint?: boolean; openWorldHint?: boolean;
};
schema: z.ZodRawShape | z.ZodObject<z.ZodRawShape>;
handler: (args: unknown) =>
Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }>;
}
(3) 단일 진실 원천 — allTools 조립
패밀리 배열을 ...로 한 배열에 합친다. 등록 순서 = 노출 순서.
[!note]- 펼쳐보기:
allTools전체 조립 코드// src/mcp/tool-registry.ts import { lspTools } from '../tools/lsp-tools.js'; import { astTools } from '../tools/ast-tools.js'; // IMPORTANT: Import from tool.js, NOT index.js! // tool.js exports pythonReplTool with wrapped handler returning { content: [...] } import { pythonReplTool } from '../tools/python-repl/tool.js'; import { stateTools } from '../tools/state-tools.js'; import { notepadTools } from '../tools/notepad-tools.js'; import { memoryTools } from '../tools/memory-tools.js'; import { traceTools } from '../tools/trace-tools.js'; import { sharedMemoryTools } from '../tools/shared-memory-tools.js'; import { deepinitManifestTool } from '../tools/deepinit-manifest.js'; import { wikiTools } from '../tools/wiki-tools.js'; import { skillsTools } from '../tools/skills-tools.js'; /** All tools exposed by the standalone server, in registration order. */ export const allTools: ToolDef[] = [ ...(lspTools as unknown as ToolDef[]), ...(astTools as unknown as ToolDef[]), pythonReplTool as unknown as ToolDef, ...(stateTools as unknown as ToolDef[]), ...(notepadTools as unknown as ToolDef[]), ...(memoryTools as unknown as ToolDef[]), ...(traceTools as unknown as ToolDef[]), ...(sharedMemoryTools as unknown as ToolDef[]), deepinitManifestTool as unknown as ToolDef, ...(wikiTools as unknown as ToolDef[]), ...(skillsTools as unknown as ToolDef[]), ];
(4) 실제 도구 한 개 — state_read
schema=Zod, annotations=읽기전용, handler=content[].text 반환이라는 규격을 그대로 따른다.
[!note]- 펼쳐보기:
state_read전문// src/tools/state-tools.ts export const stateReadTool: ToolDefinition<{ mode: z.ZodEnum<typeof STATE_TOOL_MODES>; workingDirectory: z.ZodOptional<z.ZodString>; session_id: z.ZodOptional<z.ZodString>; }> = { name: 'state_read', description: 'Read the current state for a specific mode (ralph, ultrawork, autopilot, etc.). Returns the JSON state data or indicates if no state exists.', annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, schema: { mode: z.enum(STATE_TOOL_MODES).describe('The mode to read state for'), workingDirectory: z.string().optional().describe('Working directory (defaults to cwd)'), session_id: z.string().optional().describe('Session ID for session-scoped state isolation. ...'), }, handler: async (args) => { const { mode, workingDirectory, session_id } = args; // ... 상태 파일 읽기 ... return { content: [{ type: 'text' as const, text: /* 상태 JSON 또는 안내문 */ }] }; } }; export const stateTools = [ stateReadTool, stateWriteTool, stateClearTool, stateListActiveTool, stateGetStatusTool, ];
(5) 빌드~호출까지의 생명주기
flowchart TD
B["빌드: esbuild가<br/>src/* → mcp-server.cjs (988KB)"] --> S["세션 시작: .mcp.json 읽고<br/>new Server({"name:'t'"}) · stdio"]
S --> L["ListTools: allTools 순회<br/>zodToJsonSchema 변환"]
L --> C["모델 소비: description·inputSchema 보고 선택"]
C --> CT["CallTool: name으로 find →<br/>handler(args) 실행"]
CT --> R["{"content, isError"} 반환"]
R --> X["종료: SIGINT/SIGTERM →<br/>gracefulShutdown (LSP 자식 정리)"]
[!note]- 펼쳐보기: 생명주기 6단계 상세
- 빌드:
src/mcp/standalone-server.ts(+tool-registry.ts+src/tools/*)를 esbuild가 단일 CJS 번들bridge/mcp-server.cjs(약 988KB, ajv/zod 인라인)로 묶는다. 헤더에서npm root -g로 글로벌 모듈 경로를NODE_PATH에 주입해@ast-grep/napi같은 네이티브 모듈을 찾는다.- 기동: Claude Code가
.mcp.json을 읽어t를node ${CLAUDE_PLUGIN_ROOT}/bridge/mcp-server.cjs로 띄운다.new Server({ name: 't', version: '1.0.0' }, { capabilities: { tools: {} } })후StdioServerTransport로 stdio 연결(“OMC Tools MCP Server running on stdio”).- ListTools:
buildListToolsResponse()가allTools를 순회해 각 도구를{ name, description, inputSchema, annotations }로 변환.zodToJsonSchema()가 Zod→JSON Schema(type:'object',properties,required)로 바꾸고,.optional()이 아닌 필드는 자동으로required에 들어간다.- 모델 소비: 모델은
description과inputSchema를 보고 어떤 도구를 어떤 인자로 부를지 결정한다.- CallTool:
mcp__..._t__state_read호출 시allTools.find(t => t.name === name)로 찾아tool.handler(args ?? {})실행, 결과{ content, isError }를 그대로 반환. 미존재 도구면Unknown tool: {name}+isError:true, 핸들러 예외면Error: {message}+isError:true.- 종료: SIGINT/SIGTERM 시
gracefulShutdown이 LSP 자식 프로세스를 disconnect 후server.close()(orphan jdtls 방지, 5초 하드 데드라인).
(6) 직접 만들 때 — 새 패밀리 foo 추가
// src/tools/foo-tools.ts
export const fooEchoTool: ToolDefinition<{
message: z.ZodString; loud: z.ZodOptional<z.ZodBoolean>;
}> = {
name: 'foo_echo',
description: 'Echo a message back. Optionally uppercase it. Read-only, safe to retry.',
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
schema: {
message: z.string().describe('Text to echo'),
loud: z.boolean().optional().describe('Uppercase the output'),
},
handler: async (args) => {
const { message, loud } = args;
return { content: [{ type: 'text' as const, text: loud ? message.toUpperCase() : message }] };
},
};
export const fooTools = [fooEchoTool];
// src/mcp/tool-registry.ts (2줄만 추가)
import { fooTools } from '../tools/foo-tools.js';
// ...(fooTools as unknown as ToolDef[]), ← allTools 안 원하는 위치에
[!note]- 펼쳐보기: 새 도구 추가 체크리스트
- 각 도구가
name / description / schema(zod) / handler4필드를 모두 가짐handler는 반드시{ content: [{ type: 'text', text: ... }] }반환(실패 시isError: true동봉)- 부수효과 의도를
annotations로 표기(읽기전용→readOnlyHint, 삭제→destructiveHint)- 선택 인자는
.optional()(안 붙이면 자동required로 노출)- 패밀리 배열을 export하고
allTools에 spread로 합침(.mcp.json은 안 건드림)python_repl처럼 핸들러가 원시 문자열을 반환하는 변형이면 wrapped 버전(tool.js)을 import(“Import from tool.js, NOT index.js”)- TS 수정 후
npm run build→ esbuild가bridge/mcp-server.cjs재생성(플러그인은src가 아니라 번들을 로드)
요약 & 셀프체크
- 도구를 종류별 서버로 쪼개지 않고 단일 서버
t+allTools배열 하나로 수십 개를 한꺼번에 노출한다. - 모든 도구는
ToolDef(이름·설명·schema·handler)를 따르고, 결과는 항상content[].text봉투로 통일된다. - 새 도구를 추가해도
.mcp.json은 절대 안 바뀐다 — 변경은allTools한 줄 + 패밀리 파일 +npm run build뿐이다.
스스로 답해보기:
- 서버 이름을 한 글자
t로 둔 이유는? 도구가 50개일 때 어떤 비용을 아끼는가? - 입력 인자에
.optional()을 깜빡하면 모델 화면에서 그 인자는 어떻게 노출되는가? foo-tools.ts만 만들고npm run build를 안 하면 왜 새 도구가 안 보이는가?(플러그인이 로드하는 파일이 무엇인지 떠올려 보라)
근거 파일
[!note]- 펼쳐보기: 근거 파일 전체 목록
.../.mcp.json— 서버t단일 등록.../src/mcp/tool-registry.ts—ToolDef인터페이스,allTools조립,zodToJsonSchema,buildListToolsResponse.../src/mcp/standalone-server.ts—new Server({name:'t'}), ListTools/CallTool 핸들러, stdio 전송, graceful shutdown.../src/tools/types.ts—ToolDefinition<T>,ToolAnnotations.../src/tools/state-tools.ts— state 패밀리(state_read/write/clear/list_active/get_status).../src/tools/skills-tools.ts— skills 패밀리(load_omc_skills_local/global,list_omc_skills).../src/tools/index.ts—allCustomTools(lsp/ast/python) + SDK 포맷 변환 helper(별도 경로).../bridge/mcp-server.cjs— esbuild 번들 산출물(약 988KB). 헤더npm root -g→NODE_PATH주입, 마커// src/mcp/standalone-server.ts,name: "t"
연결
- OMC_40_skills-and-commands — skills 패밀리 도구(
load_omc_skills_*)가 여기서 만든 절차서를 불러온다. - OMC_70_state-memory-persistence —
state_*도구가 기록하는.omc/외장기억의 실체. - OMC_80_teams-bridge-runtime — 단일 서버
t에서 빠진 팀 도구가 사는 별도 서버team.
[!tip] Claude ↔ Codex 교차검증 (요약 보존) 핵심 정정은 MCP 도구 수다. Claude 1차의 “80+“는 과장이며 직접 확인 결과 기준 49개(
standalone-server.test.ts:36)다. v4.4 이후 Codex/Gemini MCP는 제거되고 CLI-first로 전환됐고, 팀 런타임 도구는 별도 서버team(bridge/team-mcp.cjs)에 산다. 가장 저평가됐던 통찰은 “새 도구 패밀리를 추가해도.mcp.json은 절대 바뀌지 않는다” — 서버는 영원히t하나, 변경은allTools한 줄과 패밀리 파일뿐인 것이 본질이다.