gajae-code · MCP 통합 (외부 도구 서버)

한 줄 요약

MCP는 “AI가 외부 도구를 꽂아 쓰는 표준 콘센트”이고, gajae-code는 그 콘센트에 꽂힌 깃허브·브라우저·DB 같은 도구를 자기 내부 도구처럼 자연스럽게 빌려 쓴다. 왜 배우나 — 에이전트의 능력을 코드 수정 없이 외부 서버로 확장하는 표준 통로라서, 이걸 알면 “어떤 도구든 붙일 수 있는 에이전트”를 설계할 수 있다.

그림

flowchart TD
  A["설정 파일 발견<br/>.mcp.json / mcp.json"] --> B["연결 준비<br/>인증 토큰·환경변수 주입"]
  B --> C["서버에 접속<br/>initialize 인사 → 준비 완료"]
  C --> D["도구 목록 받기<br/>tools/list"]
  D --> E["도구 포장<br/>mcp__서버명_도구명 으로 이름 붙이기"]
  E --> F["이름순 정렬<br/>(프롬프트 캐시 안정성)"]
  F --> G["에이전트 도구 시스템에 등록<br/>모델에게 노출"]
  D -. "250ms 안에 응답 못 하면" .-> H["디스크 캐시에서 옛 목록 꺼내기<br/>(설정 해시로 신선도 검증)"]
  H --> I["나중에 연결 버전<br/>DeferredMCPTool 먼저 노출"]
  I --> F
  G --> J["모델이 mcp__... 도구 호출"]
  J --> K["execute → tools/call 실행<br/>끊기면 재연결 후 1회 재시도"]

쉽게 풀기

MCP(Model Context Protocol)를 가전제품 비유로 풀어 보자.

  1. 콘센트 규격(MCP) — 나라마다 콘센트 모양이 다르면 가전을 못 꽂는다. MCP는 “도구를 꽂는 표준 모양”을 정한 약속이다. 깃허브든 브라우저든 이 규격만 맞추면 에이전트가 꽂아 쓸 수 있다.
  2. 벽에 붙은 콘센트(MCP 서버) — 외부 기능 하나하나가 “MCP 서버”라는 작은 프로그램으로 떠 있다. 각 서버는 자기가 제공하는 도구 목록을 들고 있다.
  3. 어떤 콘센트가 있는지 찾기(디스커버리) — 에이전트는 먼저 .mcp.json 같은 설정 파일을 읽어 “이 집에 어떤 콘센트(서버)가 있나”를 파악한다.
  4. 플러그 꽂고 인사하기(연결) — 서버에 접속해 initialize라는 인사 메시지를 보내고, 서버가 답하면 연결이 성립된다. 이때 인증 토큰이나 환경변수도 같이 건넨다.
  5. 빌려온 도구에 이름표 붙이기(어댑터) — 서버가 내놓은 도구 하나하나를 에이전트가 부를 수 있는 형태(MCPTool)로 포장하고, mcp__서버명_도구명 형식의 이름표를 붙인다. 이러면 “이건 puppeteer 서버에서 빌려온 screenshot 도구”라는 게 이름만 봐도 드러난다.
  6. 느린 콘센트 대비(지연 노출) — 어떤 서버는 켜지는 데 시간이 걸린다. 시작할 때 250ms 안에 연결이 안 되면, 지난번에 저장해 둔 도구 목록(캐시)으로 “일단 이름만 띄우고, 실제 연결은 막상 호출할 때 하는” 임시 버전(DeferredMCPTool)을 먼저 보여준다. 그래야 느린 서버 하나 때문에 전체 시작이 멈추지 않는다.

도구가 너무 많을 때

서버 수십 개면 도구가 수백 개가 된다. 이걸 전부 모델에게 보여주면 토큰(설명문 비용)이 폭발한다. 그래서 검색 모드가 켜지면 모든 도구를 펼치는 대신 search_tool_bm25라는 “도구 검색 도구” 하나만 올려두고, 모델이 자연어로 검색하면 그때 관련 도구만 골라 보여준다.

핵심 정리

.mcp.json 서버 항목의 주요 필드 (discovery/mcp-json.ts, runtime-mcp/types.ts):

필드의미비고
(키) 서버이름서버 식별자도구 이름 prefix·캐시 키가 됨
type연결 방식stdio(기본) / sse / http
command·args실행할 명령stdio일 때 필수
url·headers원격 엔드포인트http/sse일 때 url 필수
auth인증 설정OAuth / API키

부품별 역할 분담:

  • loader.ts — 진입점. MCP 세계와 에이전트의 “커스텀 툴” 세계를 잇는 어댑터 경계.
  • manager.ts — 라이프사이클 두뇌. 설정 로드 → 병렬 연결 → 목록 받기 → 래핑 → 재연결/재시도. 도구를 이름순 정렬해 프롬프트 캐시가 깨지지 않게 한다.
  • client.ts — 프로토콜 담당. initialize/tools/list/tools/call 및 서버→클라 요청(ping 등) 처리. 프로토콜 버전 2025-03-26.
  • transports/ — 바이트 계층. stdio.ts(자식 프로세스 입출력), http.ts(Streamable HTTP+SSE, 401/403 시 토큰 갱신 후 재시도).

안전장치 3종

(a) 이름 충돌 방지createMCPToolName이 서버 prefix를 붙이고 중복 prefix는 제거한다. (b) 캐시 신선도 — 설정의 SHA-256 해시로 검증해, 설정이 바뀌면 옛 캐시를 자동 무효화한다. (c) 잘못된 설정 격리validateServerConfig가 틀린 항목을 연결 전에 errors로 걸러낸다.

실제 예시

최소 .mcp.json (로컬 stdio 서버 + 원격 OAuth 서버):

{
  "$schema": "https://raw.githubusercontent.com/can1357/gajae-code/main/packages/coding-agent/src/config/mcp-schema.json",
  "mcpServers": {
    "puppeteer": {
      "type": "stdio",
      "command": "bunx",
      "args": ["-y", "@modelcontextprotocol/server-puppeteer"],
      "env": { "PUPPETEER_HEADLESS": "true" },
      "timeout": 30000
    },
    "remote-api": {
      "type": "http",
      "url": "https://mcp.example.com/v1",
      "headers": { "X-Tenant": "${TENANT_ID}" },
      "auth": {
        "type": "oauth",
        "credentialId": "remote-api-oauth",
        "tokenUrl": "https://auth.example.com/oauth/token",
        "clientId": "abc123"
      }
    }
  }
}

MCP 도구를 에이전트 도구(AgentTool)로 포장하는 어댑터의 핵심:

// packages/coding-agent/src/runtime-mcp/tool-bridge.ts
export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
	readonly name: string;
	readonly label: string;
	readonly mcpToolName: string;   // 정규화 전 원본 — tools/call 에 그대로 전달
	readonly mcpServerName: string;
 
	constructor(
		private connection: MCPServerConnection,
		private readonly tool: MCPToolDefinition,
		private readonly reconnect?: MCPReconnect,
	) {
		this.name = createMCPToolName(connection.name, tool.name); // mcp__<server>_<tool>
		this.label = `${connection.name}/${tool.name}`;
		this.description = tool.description ?? `MCP tool from ${connection.name}`;
		this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
		this.mcpToolName = tool.name;
		this.mcpServerName = connection.name;
	}
 
	async execute(_toolCallId, params, _onUpdate, _ctx, signal) {
		throwIfAborted(signal);
		const args = normalizeToolArgs(params);
		try {
			const result = await callTool(this.connection, this.tool.name, args, { signal });
			return buildResult(result, this.connection.name, this.tool.name, /*provider*/…);
		} catch (error) {
			rethrowIfAborted(error, signal);
			// 네트워크/세션 끊김(econnreset, "transport closed", HTTP 404/502/503)이면
			// reconnect() 후 1회 재시도하고, 새 연결로 this.connection 을 갈아끼운다.
			if (this.reconnect && isRetriableConnectionError(error)) { … }
			return buildErrorResult(error, this.connection.name, this.tool.name, …);
		}
	}
}

도구 이름 생성 규칙 — 서버명과 도구명이 겹치면 중복 prefix를 떼어낸다:

// packages/coding-agent/src/runtime-mcp/tool-bridge.ts
export function createMCPToolName(serverName: string, toolName: string): string {
	const sanitizedServerName = sanitizeMCPToolNamePart(serverName, "server");
	const sanitizedToolName = sanitizeMCPToolNamePart(toolName, "tool");
	// 서버 "puppeteer" + 도구 "puppeteer_screenshot" → mcp__puppeteer_screenshot (중복 prefix 제거)
	const prefixWithUnderscore = `${sanitizedServerName}_`;
	let normalizedToolName = sanitizedToolName;
	if (sanitizedToolName.startsWith(prefixWithUnderscore)) {
		normalizedToolName = sanitizedToolName.slice(prefixWithUnderscore.length);
	}
	return `mcp__${sanitizedServerName}_${normalizedToolName}`;
}

내가 직접 어댑터를 만든다면, 결과에 “영수증”(어느 서버의 어느 도구에서 나왔는지)을 꼭 첨부한다:

// 직접 구현 시 최소 골격
class MyMCPTool {
  name: string;          // 반드시 mcp__<server>_<tool> 패턴 (search/필터가 prefix로 식별)
  label: string;         // <server>/<tool>
  parameters: TSchema;   // normalizeSchemaForMCP(tool.inputSchema)
  mcpToolName: string;   // 정규화 전 원본 — tools/call 에 그대로 전달
  mcpServerName: string;
 
  async execute(id, params, onUpdate, ctx, signal) {
    const result = await callTool(this.connection, this.mcpToolName, params ?? {}, { signal });
    // 영수증: 결과에 serverName/mcpToolName/isError/rawContent 첨부
    return { content: [{ type: "text", text: formatContent(result.content) }],
             details: { serverName: this.mcpServerName, mcpToolName: this.mcpToolName, isError: result.isError, rawContent: result.content } };
  }
}

직접 만들 때 체크리스트:

  • 도구 이름은 mcp__<server>_<tool>isMCPToolName/isMCPBridgeTool이 prefix로 식별하므로 필수.
  • 서버명이 도구명 prefix와 겹치면 중복 제거(createMCPToolName 규칙) 적용.
  • inputSchemanormalizeSchemaForMCP로 변환해 parameters에 넣을 것.
  • 결과·에러 모두 details(영수증)에 serverName·mcpToolName·isError 첨부.
  • 재시도: 네트워크/세션 끊김 패턴(isRetriableConnectionError)만 reconnect() 후 1회. abort는 즉시 re-throw.
  • 시작 지연 대비: 도구 정의를 config-해시 키로 캐시(TTL 30일)하고, 250ms 초과 시 DeferredMCPTool로 노출.
  • 도구 배열은 매 변경마다 이름순 정렬(프롬프트 캐시 안정성).
  • HTTP+OAuth면 transport.onAuthError를 wiring해 401/403 시 토큰 갱신 헤더 반환.

OAuth와 Smithery 레지스트리 (참고)

OAuth 자동화 — HTTP 요청이 401/403이면 헤더·본문에서 인증 서버 위치를 파싱하고, 실패하면 /.well-known/oauth-authorization-server 등을 순회 탐색한다. 이후 PKCE(S256) 기반 authorization-code 플로우(필요 시 동적 클라이언트 등록)로 토큰을 받아 agent.db에 저장하고, 만료 5분 전 또는 401 시 선제 갱신한다. (oauth-discovery.ts, oauth-flow.ts) Smithery 레지스트리registry.smithery.ai를 검색해 외부 서버를 찾고 MCPServerConfig를 자동 생성한다. http 직결이 가능하면 그대로, 아니면 bunx -y @smithery/cli run ... stdio로 폴백한다. (smithery-registry.ts)

요약 & 셀프체크

3줄 요약:

  1. MCP는 외부 도구를 표준 규격으로 꽂아 쓰는 콘센트이고, gajae-code는 서버를 찾아(discovery) → 연결하고(connect) → 도구를 mcp__서버_도구 이름의 어댑터로 포장해 모델에 노출한다.
  2. 느린 서버는 250ms 타임아웃 후 디스크 캐시 기반 DeferredMCPTool로 먼저 띄워 시작이 멈추지 않게 하고, 도구가 많으면 search_tool_bm25로 지연 검색해 토큰을 아낀다.
  3. 모든 실행 결과에는 “어느 서버의 어느 원본 도구에서 왔고 에러였는지”를 담은 영수증(MCPToolDetails)이 붙어 추적이 가능하다.

스스로 답해 보기:

  • 서버 이름이 “puppeteer”, 도구 이름이 “puppeteer_screenshot”일 때 최종 도구 이름은 무엇이 되고, 왜 그렇게 되나?
  • 시작 시 서버가 250ms 안에 응답하지 못하면 무슨 일이 벌어지며, 캐시가 신선한지는 무엇으로 판단하나?
  • 도구 실행이 네트워크 문제로 실패했을 때와 사용자가 중단(abort)했을 때, 처리 방식은 어떻게 다른가?

연결

GJ_개요 · GJ_60_tool-system-definition-and-exposure · GJ_40_subagents-and-task-delegation · GJ_70_guardrails-sandbox-permission-gating · GJ_80_gajae-receipts-and-workflow-state · _분석축_루브릭


Codex 교차검증 — 모호성 게이팅·영수증 관점 (원문 분석 보존)

이 MCP 경로 자체에는 별도의 “ambiguity gate” 단계가 없다(게이팅은 상위 deep-interview/plan 레이어 책임). MCP 계층이 제공하는 모호성 차단 메커니즘은 (a) 이름 충돌 방지(createMCPToolName의 서버 prefix + 중복 prefix 제거), (b) config 해시로 캐시 신선도 보장, (c) validateServerConfig로 잘못된 항목을 연결 전에 errors로 격리하는 것이다. 영수증/감사(receipt) 구조는 MCPToolDetails가 담당: 모든 도구 실행 결과에 serverName/mcpToolName/isError/rawContent/provider/providerName을 첨부해, “이 결과가 어느 서버의 어느 원본 도구에서 왔고 에러였는지”를 추적·렌더링할 수 있게 한다(buildResult/buildErrorResult).

근거 파일

  • packages/coding-agent/src/runtime-mcp/loader.ts — MCP→LoadedCustomTool 변환, path 포맷
  • packages/coding-agent/src/runtime-mcp/manager.ts — 디스커버리/연결/재연결/캐시폴백/정렬/OAuth 해석 라이프사이클
  • packages/coding-agent/src/runtime-mcp/client.ts — initialize/tools/list/tools/call, 서버→클라 요청 처리
  • packages/coding-agent/src/runtime-mcp/tool-bridge.tsMCPTool/DeferredMCPTool 어댑터, createMCPToolName, 재시도, MCPToolDetails(영수증)
  • packages/coding-agent/src/runtime-mcp/tool-cache.ts — config-해시 기반 도구 캐시(agent.db, TTL 30일)
  • packages/coding-agent/src/runtime-mcp/discoverable-tool-metadata.ts + tool-discovery/tool-index.ts — BM25 지연 노출/검색 인덱스
  • packages/coding-agent/src/runtime-mcp/types.ts — JSON-RPC·서버 config·프로토콜·트랜스포트 타입
  • packages/coding-agent/src/runtime-mcp/transports/stdio.ts, transports/http.ts — JSON-RPC 트랜스포트(stdio/Streamable HTTP+SSE)
  • packages/coding-agent/src/runtime-mcp/oauth-discovery.ts, oauth-flow.ts — OAuth 엔드포인트 자동 탐지 + PKCE/동적등록 플로우/토큰 갱신
  • packages/coding-agent/src/runtime-mcp/smithery-registry.ts — Smithery 레지스트리 검색→config 생성
  • packages/coding-agent/src/discovery/mcp-json.tsmcp.json/.mcp.json provider(priority 5) 디스커버리 형식
  • packages/coding-agent/src/extensibility/custom-tools/types.tsLoadedCustomTool 정의