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

그림 읽는 법

가게(서버)는 하나뿐인데 안에 “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부수효과 의도 힌트(읽기전용·파괴·멱등·외부)

실제 예시

(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 조립

패밀리 배열을 ...로 한 배열에 합친다. 등록 순서 = 노출 순서.

(4) 실제 도구 한 개 — state_read

schema=Zod, annotations=읽기전용, handler=content[].text 반환이라는 규격을 그대로 따른다.

(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) 직접 만들 때 — 새 패밀리 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 안 원하는 위치에

요약 & 셀프체크

  • 도구를 종류별 서버로 쪼개지 않고 단일 서버 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를 안 하면 왜 새 도구가 안 보이는가?(플러그인이 로드하는 파일이 무엇인지 떠올려 보라)

근거 파일

연결

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

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