지식위키

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 한 글자다.

쉽게 풀기

도구를 “공구”라 부르고 따라가 보자.

  1. 공구함은 하나뿐 — 이름이 t인 이유 드라이버 가게·망치 가게를 따로 차리는(도구 종류마다 서버 등록) 대신, OMC는 공구함 하나에 다 넣는다. 이름이 한 글자 t인 건 토큰 절약이다. 모델이 보는 도구 이름은 전부 mcp__플러그인_t__도구이름 꼴이라, 가게 이름이 길면 그 접두사가 도구 수만큼 반복돼 컨텍스트를 잡아먹는다.

  2. 공구 규격표 — ToolDef 모든 공구는 같은 양식(“이름 / 설명 / 입력 규격 / 동작 함수”)을 채워야 한다. 이게 ToolDef 인터페이스다. 양식이 통일돼 있으니 서버는 공구가 100개여도 똑같이 다룬다.

  3. 공구 목록표 — allTools 패밀리(state·notepad·lsp 등)별 묶음을 한 배열에 ...(spread)로 합친 게 allTools다. 여기 적힌 순서가 곧 모델에 보이는 순서이고, 도구 추가는 결국 이 배열에 한 줄 끼우는 일이다.

  4. 번역 — Zod → JSON Schema 입력 규격은 코드에선 Zod로 적지만 MCP 통신 규격은 JSON Schema라, 목록을 펼칠 때 zodToJsonSchema()가 통역한다. 이때 .optional()을 안 붙인 칸은 자동으로 **필수(required)**가 된다 — 깜빡하면 선택 인자가 강제 인자로 둔갑한다.

  5. 결과는 같은 봉투에 — 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모델이 “쓸까?” 판단할 때 읽는 설명문
schemaZod 입력 규격. 나갈 때 JSON Schema로 변환
handler동작 함수. 항상 content[].text 봉투 반환
annotations부수효과 의도 힌트(읽기전용·파괴·멱등·외부)

[!note]- 펼쳐보기: annotations 4종 힌트 (도구 성격표)

  • 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단계 상세

  1. 빌드: 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 같은 네이티브 모듈을 찾는다.
  2. 기동: Claude Code가 .mcp.json을 읽어 tnode ${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”).
  3. ListTools: buildListToolsResponse()allTools를 순회해 각 도구를 { name, description, inputSchema, annotations }로 변환. zodToJsonSchema()가 Zod→JSON Schema(type:'object', properties, required)로 바꾸고, .optional()이 아닌 필드는 자동으로 required에 들어간다.
  4. 모델 소비: 모델은 descriptioninputSchema를 보고 어떤 도구를 어떤 인자로 부를지 결정한다.
  5. 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.
  6. 종료: 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) / handler 4필드를 모두 가짐
  • 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뿐이다.

스스로 답해보기:

  1. 서버 이름을 한 글자 t로 둔 이유는? 도구가 50개일 때 어떤 비용을 아끼는가?
  2. 입력 인자에 .optional()을 깜빡하면 모델 화면에서 그 인자는 어떻게 노출되는가?
  3. foo-tools.ts만 만들고 npm run build를 안 하면 왜 새 도구가 안 보이는가?(플러그인이 로드하는 파일이 무엇인지 떠올려 보라)

근거 파일

[!note]- 펼쳐보기: 근거 파일 전체 목록

  • .../.mcp.json — 서버 t 단일 등록
  • .../src/mcp/tool-registry.tsToolDef 인터페이스, allTools 조립, zodToJsonSchema, buildListToolsResponse
  • .../src/mcp/standalone-server.tsnew Server({name:'t'}), ListTools/CallTool 핸들러, stdio 전송, graceful shutdown
  • .../src/tools/types.tsToolDefinition<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.tsallCustomTools(lsp/ast/python) + SDK 포맷 변환 helper(별도 경로)
  • .../bridge/mcp-server.cjs — esbuild 번들 산출물(약 988KB). 헤더 npm root -gNODE_PATH 주입, 마커 // src/mcp/standalone-server.ts, name: "t"

연결

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

[!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 한 줄과 패밀리 파일뿐인 것이 본질이다.