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 SDKdynamicTool로 투명하게 변환하여, Agent는 기반 프로토콜의 차이를 인식하지 못함 - OAuth 2.0 + PKCE 완전 구현으로 인증이 필요한 원격 MCP 서버를 지원
- 재귀적 프로세스 정리로 stdio 모드에서 고아 프로세스가 남지 않도록 보장
주요 파일
| 파일 경로 | 줄 수 | 책임 |
|---|---|---|
src/mcp/index.ts | ~922 | 모듈 메인 진입점: MCP 네임스페이스, Effect Service 패턴, 클라이언트 생성, 도구 발견, OAuth 플로우, 프로세스 정리 |
src/mcp/auth.ts | ~174 | McpAuth 네임스페이스, OAuth 토큰 Zod 스키마 정의 및 영속 저장(mcp-auth.json, 권한 0o600) |
src/mcp/oauth-callback.ts | ~217 | McpOAuthCallback 네임스페이스, 로컬 HTTP 콜백 서버(포트 19876), OAuth 리디렉션 처리 및 인증 코드 추출 |
src/mcp/oauth-provider.ts | ~186 | McpOAuthProvider 클래스, OAuthClientProvider 인터페이스 구현, OAuth 클라이언트 메타데이터, 토큰, PKCE 플로우 관리 |
참고: MCP 모듈에는 별도의 client.ts나 transport.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_auth | OAuth 인증 필요, 인증 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가지 트랜스포트 방식의 기술적 특징:
| 트랜스포트 방식 | 클래스명 | 통신 방식 | 사용 사례 |
|---|---|---|---|
| stdio | StdioClientTransport | 표준 입력/출력 파이프 | 로컬 MCP 서버 프로세스 (가장 일반적) |
| StreamableHTTP | StreamableHTTPClientTransport | HTTP POST + 스트리밍 응답 | 새로운 원격 MCP 서버 |
| SSE | SSEClientTransport | HTTP 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만 지원합니다. 자동 폴백으로 두 유형의 서버에 모두 연결할 수 있습니다 |
| 고정 콜백 포트 19876 | OAuth 콜백은 redirect_uri에 사전 등록하기 위해 예측 가능한 포트가 필요합니다. ensureRunning()이 포트 충돌을 처리하여 여러 OpenCode 인스턴스가 동일한 콜백 서버를 공유할 수 있습니다 |
| 재귀적 프로세스 정리 (BFS + 리프 우선) | stdio MCP 서버는 자식 프로세스 체인을 시작할 수 있습니다. 부모를 먼저 종료하면 자식이 init에 재배치되어 고아가 됩니다. BFS 순회 + 리프에서의 SIGTERM으로 프로세스 트리의 완전한 정리를 보장합니다 |
| 5분 OAuth 타임아웃 | OAuth는 사용자가 브라우저에서 수동 조작해야 합니다. 5분은 로그인과 승인을 완료하기에 충분한 시간을 주면서 무한 대기를 방지합니다 |
파일 권한 0o600 | OAuth 토큰은 비밀번호와 동등합니다. 0o600(소유자만 읽기/쓰기)은 다중 사용자 시스템에서 기본적인 보안 조치입니다 |
concurrency: "unbounded" 병렬 초기화 | MCP 서버는 완전히 독립적입니다. 하나가 느려도 다른 것에 영향을 주지 않습니다. 동시성 제한 없이 모든 서버가 최대한 빨리 준비될 수 있습니다 |
다른 모듈과의 관계
- Agent: Agent는 통합 도구 인터페이스를 통해 MCP 도구를 호출하며, 내장 도구와 구별할 수 없습니다.
convertMcpTool()이 MCP 도구를 AI SDKdynamicTool로 래핑하고, 도구 설명이 Agent 컨텍스트에 자동으로 주입됩니다 - Config:
Config.mcp가 MCP 서버 목록을 정의하며, 각 항목은type(stdio/url),command,args,env,url,disabled등의 필드를 포함합니다 - Instance: MCP 상태는
Instance.state()를 통해 관리되며, 프로젝트 인스턴스의 수명 주기를 따릅니다.start()/stop()은 인스턴스 초기화/종료 시 자동으로 호출됩니다 - Bus:
ToolsChangedBusEvent(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 호출 흐름에 원활하게 통합됩니다