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
그림 읽는 법
가게(서버)는 하나뿐인데 안에 “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
부수효과 의도 힌트(읽기전용·파괴·멱등·외부)
펼쳐보기: annotations 4종 힌트 (도구 성격표)
readOnlyHint: true — 상태를 안 바꿈(읽기 전용). 클라이언트의 우선 로딩 판단에 사용.
destructiveHint: true — 삭제 등 파괴 동작 가능(readOnly가 false일 때만 의미).
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 필드가 동일해 안전하게 섞인다.
// src/mcp/tool-registry.tsimport { 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 반환이라는 규격을 그대로 따른다.
펼쳐보기: state_read 전문
// src/tools/state-tools.tsexport 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 자식 정리)"]
펼쳐보기: 생명주기 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초 하드 데드라인).
핵심 정정은 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 한 줄과 패밀리 파일뿐인 것이 본질이다.