컨텐츠로 건너뛰기

MCP 소스 코드 분석

모듈 개요

MCP(Model Context Protocol) 모듈은 OpenCode의 외부 도구 통합 채널로, packages/opencode/src/mcp/에 위치합니다. MCP 프로토콜을 통해 OpenCode는 데이터베이스 클라이언트, 브라우저 자동화, 검색 엔진, 코드 분석 도구 등 다양한 외부 도구 서버에 연결할 수 있으며, 그 기능을 Agent가 호출 가능한 도구로 균일하게 공개합니다. MCP를 통해 OpenCode의 기능은 내장 도구 세트를 넘어 확장됩니다.

모듈의 핵심 진입점 index.ts(약 922줄)는 MCP 네임스페이스를 공개하며, Effect Service 아키텍처 패턴을 채택합니다. start(), stop(), client(), status(), tools() 등의 함수를 노출합니다. 내부적으로 Instance.state()를 통해 전역 상태를 관리하며, clients: Record<string, Client>status: Record<string, Status>를 유지합니다. 각 MCP 서버 연결은 독립적으로 관리되어, 하나의 서버가 충돌해도 다른 서버에 영향을 주지 않습니다.

핵심 설계 선택:

  • Effect Service 패턴으로 타입 안전한 의존성 주입과 리소스 수명 주기 관리를 제공
  • 판별 공용체 타입으로 5개의 클라이언트 상태를 정확하게 표현
  • **convertMcpTool**이 MCP 도구를 AI SDK dynamicTool로 투명하게 변환하여, Agent는 기반 프로토콜의 차이를 인식하지 못함
  • OAuth 2.0 + PKCE 완전 구현으로 인증이 필요한 원격 MCP 서버를 지원
  • 재귀적 프로세스 정리로 stdio 모드에서 고아 프로세스가 남지 않도록 보장

주요 파일

파일 경로줄 수책임
src/mcp/index.ts~922모듈 메인 진입점: MCP 네임스페이스, Effect Service 패턴, 클라이언트 생성, 도구 발견, OAuth 플로우, 프로세스 정리
src/mcp/auth.ts~174McpAuth 네임스페이스, OAuth 토큰 Zod 스키마 정의 및 영속 저장(mcp-auth.json, 권한 0o600)
src/mcp/oauth-callback.ts~217McpOAuthCallback 네임스페이스, 로컬 HTTP 콜백 서버(포트 19876), OAuth 리디렉션 처리 및 인증 코드 추출
src/mcp/oauth-provider.ts~186McpOAuthProvider 클래스, OAuthClientProvider 인터페이스 구현, OAuth 클라이언트 메타데이터, 토큰, PKCE 플로우 관리

참고: MCP 모듈에는 별도의 client.tstransport.ts 파일이 없습니다. 트랜스포트 계층 구현은 @modelcontextprotocol/sdk npm 패키지에서 제공되며, 클라이언트 로직은 index.ts에 완전히 집중되어 있습니다. 이 설계는 프로토콜 구현을 SDK에 위임하고, OpenCode는 연결 관리, 도구 변환, 인증 오케스트레이션에 집중합니다.

타입 시스템

Status — 클라이언트 상태 판별 공용체

const Status = z.discriminatedUnion("status", [
  z.object({ status: z.literal("connected") }),
  z.object({ status: z.literal("disabled") }),
  z.object({ status: z.literal("failed"), error: z.string() }),
  z.object({ status: z.literal("needs_auth"), url: z.string() }),
  z.object({ status: z.literal("needs_client_registration") }),
])

5개 상태의 의미와 전환 경로:

상태의미소스
connected정상 연결, 도구 사용 가능create() 성공, finishAuth() 성공
disabled사용자가 설정에서 이 서버를 비활성화함start()mcp.disabled 확인
failed연결 실패create() 예외 발생, OAuth 실패
needs_authOAuth 인증 필요, 인증 URL 포함원격 서버가 401 반환
needs_client_registration먼저 OAuth 클라이언트를 등록해야 함원격 서버에서 등록된 클라이언트를 발견하지 못함

상태 전환 체인:

disabled ──────────────────────────────────── (설정 제어, 연결에 관여하지 않음)
needs_client_registration → needs_auth → connected
                                ↑               ↓
                             failed  ←──── (연결/OAuth 실패)

코어 플로우

트랜스포트 계층과 클라이언트 생성

MCP.create(key, mcp)는 클라이언트 생성의 핵심 함수입니다. 설정에 따라 트랜스포트 방식을 선택합니다:

// 로컬 프로세스: stdio 트랜스포트
if (mcp.type === "stdio") {
  transport = new StdioClientTransport({
    command: mcp.command,
    args: mcp.args,
    env: { ...process.env, ...mcp.env },
    stderr: "pipe",
  })
}

// 원격 서비스: StreamableHTTP 우선, SSE 폴백
if (mcp.url) {
  const url = new URL(mcp.url)
  transport = new StreamableHTTPClientTransport(url, {
    authProvider: hasStoredCredentials ? oAuthProvider : undefined,
  })
}

3가지 트랜스포트 방식의 기술적 특징:

트랜스포트 방식클래스명통신 방식사용 사례
stdioStdioClientTransport표준 입력/출력 파이프로컬 MCP 서버 프로세스 (가장 일반적)
StreamableHTTPStreamableHTTPClientTransportHTTP POST + 스트리밍 응답새로운 원격 MCP 서버
SSESSEClientTransportHTTP Server-Sent Events레거시 원격 서버 호환성

원격 트랜스포트는 자동 폴백을 지원합니다: 먼저 StreamableHTTPClientTransport를 시도하고, 서버가 지원하지 않으면 SSEClientTransport로 폴백합니다.

Effect 리소스 관리 — acquireUseRelease

create() 함수는 Effect의 acquireUseRelease 패턴을 사용하여 트랜스포트 연결의 수명 주기를 관리합니다:

yield* Effect.acquireUseRelease(
  // acquire: 연결 확립
  Effect.tryPromise(() => client.connect(transport)),
  // use: 성공 후 작업 (도구 목록 가져오기 등)
  async (connected) => { /* ... */ },
  // release: 성공/실패 관계없이 트랜스포트가 닫히도록 보장
  (connected, exit) => {
    if (exit._tag === "Failure") {
      return Effect.tryPromise(() => transport.close())
    }
  },
)

이 패턴은 연결 중 에러가 발생해도 트랜스포트가 적절히 닫히도록 보장하여, 파일 디스크립터나 자식 프로세스 누수를 방지합니다.

모든 MCP 서버의 병렬 초기화

start() 함수는 Effect의 forEach를 사용하여 설정된 모든 MCP 서버를 병렬로 초기화합니다:

yield* Effect.forEach(
  Object.entries(config.mcp),
  ([key, mcp]) => create(key, mcp),
  { concurrency: "unbounded" },
)

concurrency: "unbounded"는 모든 서버가 동시에 시작됨을 의미합니다. 하나의 느린 서버가 다른 서버를 차단하지 않습니다.

convertMcpTool — MCP 도구에서 AI SDK 도구로의 브릿지

이것은 MCP 모듈에서 가장 중요한 변환 함수로, MCP 프로토콜 도구 정의를 AI SDK dynamicTool로 변환합니다:

function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
  const schema: JSONSchema7 = {
    ...(inputSchema as JSONSchema7),
    type: "object",
    properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
    additionalProperties: false,  // 추가 속성 허용하지 않음
  }
  return dynamicTool({
    description: mcpTool.description ?? "",
    inputSchema: jsonSchema(schema),
    execute: async (args: unknown) => {
      return client.callTool({
        name: mcpTool.name,
        arguments: (args || {}) as Record<string, unknown>,
      }, CallToolResultSchema, {
        resetTimeoutOnProgress: true,
        timeout,
      })
    },
  })
}

주요 설계 결정:

  • 매개변수 스키마 패스스루: MCP 도구의 inputSchema를 AI SDK 도구의 매개변수 정의로 직접 사용
  • additionalProperties: false: 배타적 엄격 검증 — LLM 생성 매개변수가 스키마와 정확히 일치해야 함
  • resetTimeoutOnProgress: 장시간 실행 도구가 진행 업데이트를 수신할 때 타임아웃 자동 리셋
  • 명명 규칙: sanitizedClientName + "_" + sanitizedToolName으로 서로 다른 MCP 서버의 도구 이름 충돌 방지

tools/list_changed 알림 처리

MCP 프로토콜은 서버가 런타임에 동적으로 도구를 추가하거나 제거하는 것을 지원합니다. watch() 함수가 tools/list_changed 알림을 수신합니다:

client.setNotificationHandler(ToolsListChangedNotificationSchema, async () => {
  const newTools = await defs(client, key, mcp)
  Bus.publish(Event.ToolsChanged, { client: key })
})

이를 통해 Agent는 재시작 없이 MCP 서버가 동적으로 추가한 도구에 접근할 수 있습니다.

OAuth 2.0 + PKCE 인증 플로우

원격 MCP 서버는 OAuth 인증이 필요할 수 있습니다. OpenCode는 3개 파일에 걸쳐 완전한 OAuth 2.0 + PKCE 플로우를 구현합니다:

1. startAuth(key, mcp)
   ├─ 랜덤 state 매개변수 생성 (CSRF 보호)
   ├─ McpOAuthProvider 인스턴스 생성
   ├─ 원격 서버로 연결 시도
   └─ authorizationUrl 캡처

2. authenticate(key, mcp)
   ├─ startAuth() 호출로 인증 URL 획득
   ├─ 사용자 브라우저 열기 (Open.browser)
   └─ waitForCallback(oauthState, mcpName) ← 블로킹 대기, 5분 타임아웃

3. 브라우저 콜백 → McpOAuthCallback 처리
   ├─ http://localhost:19876/mcp/oauth/callback으로 리디렉션
   ├─ state 매개변수 검증 (CSRF 보호)
   └─ 인증 코드 파싱

4. finishAuth(key, mcp, code)
   ├─ transport.finishAuth(code)로 액세스 토큰 교환
   └─ createAndStore()로 자격 증명 영속화

5. McpAuth.set() → mcp-auth.json에 쓰기 (권한 0o600)

McpAuth — 토큰 영속화

// 파일 경로: Global.Path.data/mcp-auth.json
// 파일 권한: 0o600 (소유자만 읽기/쓰기 가능)
async function writeJson(data: Entry[]): Promise<void> {
  await FileSystem.writeFile(
    Global.Path.data + "/mcp-auth.json",
    JSON.stringify(data, null, 2),
    { mode: 0o600 },  // 엄격한 파일 권한
  )
}

주요 보안 조치:

  • 파일 권한 0o600: 다른 사용자가 OAuth 토큰을 읽지 못하도록 보장
  • URL 매칭 검증: getForUrl(serverUrl)이 자격 증명을 반환할 때 serverUrl이 일치하는지 확인
  • 토큰 만료 확인: isTokenExpired()expiresAt 필드를 확인; 만료된 토큰은 자동으로 사용되지 않음

프로세스 정리

MCP 모듈은 stdio 트랜스포트 모드에서 특별한 프로세스 정리 요구사항이 있습니다. 로컬 MCP 서버는 일반적으로 자식 프로세스로 시작되며, 이 자식 프로세스 자체가 자식 프로세스를 생성할 수 있습니다. OpenCode는 재귀적 프로세스 정리를 구현하여 고아 프로세스가 남지 않도록 보장합니다.

정리 전략

1. client.close()로 MCP 클라이언트 연결 종료
2. stdio 트랜스포트가 있는지 확인 (pid 속성이 있는지)
   ├─ pid 없음 (원격 트랜스포트) → 정리 완료
   └─ pid 있음 (stdio 트랜스포트) → 계속
3. descendants(pid)로 모든 하위 프로세스 획득
4. 리프 프로세스(목록의 끝)에서부터 SIGTERM 전송
5. 5초 대기
6. 아직 살아있으면? SIGKILL로 강제 종료
7. pendingOAuthTransports Map 정리

리프 프로세스에서부터 SIGTERM을 보내는 이유: 부모 프로세스를 먼저 종료하면 자식 프로세스가 init 프로세스에 재배치되어 고아가 될 수 있습니다. 리프 프로세스를 먼저 종료하면 프로세스 트리를 아래에서 위로 완전히 정리할 수 있습니다.

호출 체인 예제

체인 1: 설정 로딩 → MCP 시작 → 도구 등록

1. Instance.init() 트리거 → MCP.start() 호출


2. Config.mcp에서 서버 목록 읽기
   예: { "github": { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] } }


3. Effect.forEach({ concurrency: "unbounded" }) 병렬 초기화
   각 설정에 대해 create(key, mcp) 호출:
   ├─ mcp.disabled 확인 → disabled 상태
   ├─ 트랜스포트 계층 선택:
   │  ├─ stdio → StdioClientTransport → 자식 프로세스 시작
   │  └─ url → StreamableHTTPClientTransport (SSE 폴백)
   ├─ Effect.acquireUseRelease로 연결 확립
   └─ status[key] 상태 업데이트


4. defs(client, key, mcp) 도구 발견
   ├─ client.listTools()로 도구 목록 획득 (타임아웃 보호 포함)
   └─ convertMcpTool()로 각 도구를 dynamicTool로 변환
      예: { name: "search_repositories" } → dynamicTool "github_search_repositories"


5. Bus.publish(ToolsChanged)로 Agent에게 도구 목록 업데이트 통지

체인 2: Agent가 MCP 도구 호출

1. LLM이 도구 이름 "github_search_repositories"로 tool_use 반환


2. Agent 도구 라우팅이 이 MCP 도구에 매치
   → dynamicTool.execute({ query: "opencode" })


3. convertMcpTool 내부에서:
   client.callTool({
     name: "search_repositories",
     arguments: { query: "opencode" },
   }, CallToolResultSchema, {
     resetTimeoutOnProgress: true,
     timeout: 30000,
   })


4. MCP SDK가 트랜스포트 계층을 통해 MCP 서버에 요청 전송
   ├─ stdio: 자식 프로세스의 stdin에 쓰기
   └─ HTTP: 원격 서버에 POST 요청


5. MCP 서버가 요청을 처리하고 결과 반환


6. 결과가 Agent 도구의 반환 값을 통해 LLM에 전달됨

설계 트레이드오프

결정근거
Effect Service 패턴타입 안전한 의존성 주입과 리소스 수명 주기 관리를 제공합니다. acquireUseRelease는 트랜스포트 연결이 모든 경우에 적절히 닫히도록 보장하고, addFinalizer는 자식 프로세스가 재귀적으로 정리되도록 보장합니다
MCP SDK 트랜스포트 계층 위임트랜스포트 프로토콜의 복잡한 세부 사항(stdio 파이프 관리, HTTP SSE 파싱, 프로토콜 버전 협상)은 @modelcontextprotocol/sdk가 처리하며, OpenCode는 연결 오케스트레이션과 도구 변환에 집중합니다
additionalProperties: false배타적 엄격 검증. MCP 도구 매개변수 스키마는 불완전할 수 있지만, AI SDK는 LLM 생성 매개변수가 엄격하게 일치하기를 요구합니다. 예상치 못한 필드를 MCP 서버에 전달하는 것보다 LLM이 재시도하는 것이 낫습니다
매개변수 스키마 직접 패스스루MCP 스키마 → AI SDK 스키마 매핑 변환이 없습니다. MCP의 inputSchema는 이미 JSON Schema이며 AI SDK의 jsonSchema()와 호환됩니다. 변환 계층이 적을수록 버그도 적습니다
StreamableHTTP → SSE 자동 폴백MCP 프로토콜은 SSE에서 StreamableHTTP로 마이그레이션 중이지만, 많은 서버가 여전히 SSE만 지원합니다. 자동 폴백으로 두 유형의 서버에 모두 연결할 수 있습니다
고정 콜백 포트 19876OAuth 콜백은 redirect_uri에 사전 등록하기 위해 예측 가능한 포트가 필요합니다. ensureRunning()이 포트 충돌을 처리하여 여러 OpenCode 인스턴스가 동일한 콜백 서버를 공유할 수 있습니다
재귀적 프로세스 정리 (BFS + 리프 우선)stdio MCP 서버는 자식 프로세스 체인을 시작할 수 있습니다. 부모를 먼저 종료하면 자식이 init에 재배치되어 고아가 됩니다. BFS 순회 + 리프에서의 SIGTERM으로 프로세스 트리의 완전한 정리를 보장합니다
5분 OAuth 타임아웃OAuth는 사용자가 브라우저에서 수동 조작해야 합니다. 5분은 로그인과 승인을 완료하기에 충분한 시간을 주면서 무한 대기를 방지합니다
파일 권한 0o600OAuth 토큰은 비밀번호와 동등합니다. 0o600(소유자만 읽기/쓰기)은 다중 사용자 시스템에서 기본적인 보안 조치입니다
concurrency: "unbounded" 병렬 초기화MCP 서버는 완전히 독립적입니다. 하나가 느려도 다른 것에 영향을 주지 않습니다. 동시성 제한 없이 모든 서버가 최대한 빨리 준비될 수 있습니다

다른 모듈과의 관계

  • Agent: Agent는 통합 도구 인터페이스를 통해 MCP 도구를 호출하며, 내장 도구와 구별할 수 없습니다. convertMcpTool()이 MCP 도구를 AI SDK dynamicTool로 래핑하고, 도구 설명이 Agent 컨텍스트에 자동으로 주입됩니다
  • Config: Config.mcp가 MCP 서버 목록을 정의하며, 각 항목은 type(stdio/url), command, args, env, url, disabled 등의 필드를 포함합니다
  • Instance: MCP 상태는 Instance.state()를 통해 관리되며, 프로젝트 인스턴스의 수명 주기를 따릅니다. start()/stop()은 인스턴스 초기화/종료 시 자동으로 호출됩니다
  • Bus: ToolsChanged BusEvent(BusEvent.define("mcp.tools.changed"))가 도구 목록 변경을 알리며, Agent와 UI 레이어가 업데이트를 구독합니다
  • Global: Global.Path.data/mcp-auth.json이 OAuth 토큰을 저장합니다. 파일 권한 0o600이 보안을 보장합니다
  • Permission: MCP 도구 호출도 Permission 시스템의 관리 하에 있습니다. 사용자는 특정 MCP 도구의 실행을 승인하거나 거부할 수 있으며, 내장 도구와 동일한 권한 규칙을 사용합니다
  • Provider: MCP 도구의 실행 결과는 dynamicTool을 통해 Agent에 반환되어 Provider 모듈의 LLM 호출 흐름에 원활하게 통합됩니다