얼마 전에 cokacremote를 Docker로 띄워서 ChatGPT와 제 개발환경을 연결해봤습니다.
처음에는 그냥
ChatGPT가 내 컴퓨터의 파일을 읽고 명령을 실행할 수 있구나.
정도로 생각했습니다.
그런데 실제로 사용하다 보니 궁금해졌습니다.
ChatGPT에서 Tool 하나를 호출하면 그 요청은 도대체 어디로 들어오는 걸까?
MCP라는 이름은 계속 나오는데,
HTTP 요청이 어디에서 받아지고,
MCP Server는 어디서 만들어지고,
exec_command 같은 Tool은 어떻게 연결되는지 제대로 본 적은 없었습니다.
그래서 그냥 소스를 열어봤습니다.
README만 보고 넘어가면 또 궁금할 것 같았습니다.
이번에는 cokacremote 코드를 직접 따라가 보겠습니다.
대충 알고 있던 이 흐름이 실제 코드에서는 어떻게 이어지는지 하나씩 보겠습니다.
분석 기준은 다음 커밋입니다.
repository: https://github.com/lahuman/cokacremote
commit: 1bb0f767c4249e8536bcee98341808bebcbbebaa
date: 2026-08-21
코드는 앞으로 바뀔 수 있으니 이 글에서 파일명과 코드가 다르게 보인다면 위 커밋을 기준으로 보면 됩니다.
먼저 프로젝트 구조를 봤습니다.
일단 파일이 꽤 많습니다.
전부 볼 필요는 없으니 서버가 시작되는 쪽부터 추려봤습니다.
src/
├── auth.ts
├── config.ts
├── exec-tools.ts
├── file-service.ts
├── file-tools.ts
├── http-server.ts
├── mcp-server.ts
├── process-manager.ts
└── server.ts
이번 글에서 볼 흐름은 대략 이렇습니다.
server.ts
↓
config.ts
↓
http-server.ts
↓
mcp-server.ts
↓
exec-tools.ts / file-tools.ts
처음에는 이름만 보고 mcp-server.ts부터 열어볼까 했습니다.
MCP Server니까 왠지 여기서 시작할 것 같았습니다.
그런데 아니었습니다.
일단 어디서 시작하는지 봤습니다.
이런 건 package.json부터 보면 빠릅니다.
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/server.ts",
"start": "node dist/src/server.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
개발 환경에서는
npm run dev
를 실행하면 tsx watch src/server.ts가 실행됩니다.
production에서는 TypeScript를 먼저 build한 다음
npm start
를 실행하고, 이때는 dist/src/server.js가 시작됩니다.
결국 개발이든 production이든 원본 기준 시작점은 src/server.ts입니다.
찾았습니다.
여기부터 보면 됩니다.
server.ts를 열어봤습니다.
src/server.ts를 열어보면 시작 부분은 이렇습니다.
async function main(): Promise<void> {
const config = loadConfig();
const services = createServices(config);
const running = await startHttpServer(config, services);
딱 세 줄입니다.
생각보다 단순합니다.
그런데 이 세 줄만 봐도 전체 흐름이 거의 보입니다.
loadConfig
↓
createServices
↓
startHttpServer
하나씩 보면 어렵지 않습니다.
loadConfig()에서 설정을 읽고,
createServices()에서 명령 실행과 파일 작업에 필요한 객체를 만들고,
마지막으로 startHttpServer()에서 HTTP 서버를 띄웁니다.
server.ts에서 모든 걸 처리하는 건 아니었습니다.
필요한 것들을 만들고 연결해서 서버를 시작하는 정도입니다.
설정부터 따라가 봤습니다.
loadConfig()는 src/config.ts에 있습니다.
환경변수가 꽤 많습니다.
지금 다 볼 건 아니고, 서버 시작에 필요한 것만 보면 이 정도입니다.
return {
host: env.MCP_HOST?.trim() || "0.0.0.0",
port: parseInteger(env.MCP_PORT, 3000, "MCP_PORT", 1, 65_535),
endpoint,
publicUrl,
// ...
defaultCwd,
defaultShell:
env.MCP_DEFAULT_SHELL?.trim() || env.SHELL?.trim() || "/bin/bash",
};
별도로 값을 주지 않으면 기본값은 이렇습니다.
host 0.0.0.0
port 3000
endpoint /mcp
shell /bin/bash
endpoint도 같은 파일에서 만들어집니다.
function normalizeEndpoint(value: string | undefined): string {
const endpoint = value?.trim() || "/mcp";
그래서 기본 MCP endpoint는 /mcp입니다.
여기서 하나 걸리는 부분이 있었습니다.
인증 설정이 없으면 그냥 서버가 시작되는 구조가 아닙니다.
if (!allowNoAuth && !authToken && !oauthEnabled) {
throw new Error(
"MCP_AUTH_TOKEN is required. Set MCP_ALLOW_NO_AUTH=true only when an upstream OAuth gateway or private network authenticates callers.",
);
}
기본 상태에서는 Bearer Token이나 OAuth 같은 인증이 필요합니다.
MCP_ALLOW_NO_AUTH=true로 인증을 끌 수도 있습니다.
다만 코드의 에러 메시지를 보면 upstream gateway나 private network처럼 앞단에서 인증이 보장되는 경우를 전제로 하고 있습니다.
제가 쓰는 환경도 앞단에 nginx proxy가 있습니다.
다만 이건 제가 그렇게 구성한 것이고 cokacremote 자체의 기본 구조는 아닙니다.
그 다음은 Service입니다.
server.ts의 두 번째 줄은 이것이었습니다.
const services = createServices(config);
createServices()는 src/mcp-server.ts에 있습니다.
export function createServices(config: AppConfig): McpServices {
return {
processManager: new ProcessManager({
maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes,
processRetentionMs: config.processRetentionMs,
maxProcesses: config.maxProcesses,
defaultMaxOutputBytes: config.maxOutputBytes,
}),
fileService: new FileService({
defaultCwd: config.defaultCwd,
maxChunkBytes: config.maxFileChunkBytes,
maxEditFileBytes: config.maxEditFileBytes,
maxOutputBytes: config.maxOutputBytes,
}),
};
}
여기서 실제 작업에 쓰일 객체 두 개를 만듭니다.
ProcessManager
FileService
ProcessManager는 나중에 exec_command 같은 명령 실행과 장기 프로세스를 관리할 때 사용합니다.
FileService는 파일 읽기, 쓰기, patch 같은 파일 작업에 사용합니다.
여기서 조금 재미있었습니다.
MCP Server보다 ProcessManager와 FileService를 먼저 만듭니다.
뒤에서 Tool을 등록할 때 이 객체들을 넘겨서 쓰기 때문입니다.
이제 HTTP Server 쪽으로 갑니다.
다시 server.ts로 돌아가면 세 번째 줄이 나옵니다.
const running = await startHttpServer(config, services);
startHttpServer()는 src/http-server.ts에 있습니다.
여기서 어떤 HTTP 서버를 쓰는지도 바로 보입니다.
import express, { type Request, type Response } from "express";
package.json을 확인해보면 현재 버전은 Express 5.2.1입니다.
실제로 함수 안에서도 Express 애플리케이션을 만듭니다.
const app = express();
app.disable("x-powered-by");
여기까지 따라오면 이런 모양입니다.
물론 Express가 MCP를 알아서 처리해주는 건 아닙니다.
이제 MCP SDK가 나옵니다.
MCP Transport를 찾았습니다.
http-server.ts 상단을 보면 다음 import가 있습니다.
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
이 프로젝트는 MCP SDK의 StreamableHTTPServerTransport를 사용합니다.
실제 POST 요청을 처리하는 postHandler 안을 보면 Transport를 만드는 코드가 있습니다.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
여기서 제일 먼저 눈에 들어온 건
sessionIdGenerator: undefined
였습니다.
현재 구현은 HTTP MCP session을 계속 들고 가는 방식이 아닙니다.
stateless 방식입니다.
/health 응답에서도 이 값을 직접 확인할 수 있습니다.
transportMode: "stateless-json",
activeMcpSessions: 0,
테스트 코드에서도 MCP 연결 후
expect(transport.sessionId).toBeUndefined();
를 확인하고 있습니다.
요청 하나가 들어오면 그 요청을 독립적으로 처리합니다.
그럼 MCP Server는 언제 만들까?
저는 서버가 뜰 때 MCP Server도 하나 만들어놓고 계속 쓸 거라고 생각했습니다.
코드를 보니 아니었습니다.
같은 postHandler 안을 보면 Transport 바로 다음에 나옵니다.
const server = createMcpServer(config, services);
그러니까 POST 요청이 들어올 때마다 MCP Server도 새로 만듭니다.
그리고 이어서 Transport와 연결합니다.
await server.connect(transport);
await transport.handleRequest(request, response, request.body);
여기까지 보니 요청이 어디로 지나가는지 거의 보입니다.
그럼 실제 URL은 어디일까요?
요청은 /mcp로 들어옵니다.
http-server.ts 아래쪽을 보면 route가 등록됩니다.
app.post(
config.endpoint,
authenticate,
parseMcpJson,
(request, response) => {
void postHandler(request, response);
},
);
앞에서 config.endpoint 기본값이 /mcp인 것도 봤습니다.
그래서 기본 설정에서는 결국
POST /mcp
로 요청이 들어옵니다.
순서도 한번 봤습니다.
POST /mcp
↓
authenticate
↓
JSON parse
↓
postHandler
integration test를 보면 더 확실합니다.
인증이 없으면 401, 인증은 통과했지만 JSON이 잘못되면 400입니다.
인증을 먼저 보고 그 다음 body를 처리합니다.
이제 mcp-server.ts를 봤습니다.
다시 src/mcp-server.ts로 갑니다.
핵심은 createMcpServer()입니다.
export function createMcpServer(config: AppConfig, services: McpServices): McpServer {
const server = new McpServer(
{
name: "cokacremote",
version: "0.1.0",
...(config.publicUrl ? { websiteUrl: config.publicUrl } : {}),
},
{
instructions:
"This server is an unrestricted remote development environment...",
capabilities: { logging: {} },
},
);
여기서 MCP SDK의 McpServer 객체가 생성됩니다.
서버 이름은 cokacremote, 버전은 0.1.0입니다.
그 다음 코드가 더 중요합니다.
registerExecTools(
server,
config,
services.processManager,
services.fileService,
);
registerFileTools(server, config, services.fileService);
여기 있었습니다.
Tool을 등록하는 부분입니다.
실행 관련 Tool은 registerExecTools(),
파일 관련 Tool은 registerFileTools()가 등록합니다.
코드 그대로 연결하면 이런 구조입니다.
exec_command도 잠깐 봤습니다.
src/exec-tools.ts를 열면 exec_command가 나옵니다.
server.registerTool(
"exec_command",
{
title: "Execute command",
description:
"Run an unrestricted shell command on the host...",
inputSchema: {
cmd: z.string().min(1),
workdir: z.string().optional(),
shell: z.string().optional(),
// ...
},
},
여기서 등록하는 건 크게
Tool 이름
설명
입력 Schema
실행 Handler
입니다.
입력 Schema는 Zod로 정의합니다.
MCP Client는 Tool 이름만 보는 게 아니라 어떤 인자를 넣어야 하는지도 이 Schema로 알 수 있습니다.
예를 들어 exec_command에는 실제 코드 기준으로 다음과 같은 입력이 있습니다.
cmd
workdir
shell
login
env
stdin
timeoutMs
yieldTimeMs
maxOutputBytes
그리고 Handler에서는 결국 ProcessManager를 사용합니다.
const sessionId = processManager.start({
executable,
args: [login ? "-lc" : "-c", cmd],
commandForDisplay: cmd,
cwd,
env,
timeoutMs,
stdin,
});
여기부터 실제 명령 실행으로 내려갑니다.
이건 2부에서 제대로 보겠습니다.
파일 쪽도 비슷합니다.
src/file-tools.ts도 구조는 비슷합니다.
예를 들어 read_file은 다음처럼 등록됩니다.
server.registerTool(
"read_file",
{
title: "Read file",
description:
"Read a bounded chunk of any host file as UTF-8 text or base64...",
inputSchema: {
path: pathSchema,
cwd: cwdSchema,
offset: z.number().int().min(0).default(0),
// ...
},
},
write_file, apply_patch, upload_file, download_file 같은 Tool도 모두 이 파일에서 등록됩니다.
특히 apply_patch가 여기 있다는 것도 확인했습니다.
server.registerTool(
"apply_patch",
{
title: "Apply unified diff",
description:
"Validate and apply a standard unified diff with git apply. Paths are unrestricted and --unsafe-paths is enabled.",
여기서 git apply와 --unsafe-paths가 바로 보입니다.
이건 3부에서 자세히 볼 내용이라 지금은 일단 넘어가겠습니다.
처음 질문으로 다시 돌아가봤습니다.
처음 궁금했던 건 이거였습니다.
ChatGPT에서
exec_command를 호출하면 어디로 들어올까?
실제 코드를 연결해보면 이렇게 됩니다.
함수 이름까지 넣으면 이렇습니다.
ChatGPT
↓
POST /mcp
↓
startHttpServer()
↓
postHandler()
↓
new StreamableHTTPServerTransport()
↓
createMcpServer()
↓
registerExecTools()
↓
exec_command handler
↓
ProcessManager.start()
처음에는 그냥 “MCP Server가 명령 실행하겠지” 정도로 생각했습니다.
그런데 까보니 역할이 꽤 잘 나뉘어 있었습니다.
HTTP 요청은 Express가 받고,
MCP 프로토콜 처리는 SDK의 Transport와 McpServer가 맡고,
Tool 정의는 별도 모듈에 있고,
실제 프로세스 실행은 다시 ProcessManager로 내려갑니다.
테스트 코드도 봤습니다.
test/mcp.integration.test.ts를 보면 실제 MCP Client를 만들어 서버에 연결합니다.
const client = new Client({ name: "integration-test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(endpoint, {
requestInit: {
headers: { Authorization: "Bearer integration-secret" },
},
});
await client.connect(transport);
그리고 Tool 목록을 가져와 실제 등록 여부를 확인합니다.
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toEqual(
expect.arrayContaining([
"exec_command",
"run_script",
"write_stdin",
"read_file",
"write_file",
"apply_patch",
"upload_file",
"download_file",
]),
);
소스만 보고 추측한 건 아닙니다.
테스트에서도 실제 MCP Client가 Tool 목록을 받아오는지 확인합니다.
또 다른 테스트에서는 JSON-RPC 요청을 직접 만들어 tools/call을 보냅니다.
method: "tools/call",
params: {
name: "exec_command",
arguments: {
cmd: "node -e \"setTimeout(() => console.log('stateless-ok'), 100)\"",
yieldTimeMs: 0,
},
},
그리고 다음 요청에서 read_process를 호출해 실행 결과를 다시 읽습니다.
여기까지 오니 다음 궁금증도 생깁니다.
exec_commandHandler가ProcessManager.start()를 호출한 다음 실제 OS에서는 무슨 일이 일어나는가?
여기까지 봤습니다.
이번에는 cokacremote가 어디서 시작해서 MCP Tool까지 가는지만 따라가 봤습니다.
생각보다 흐름은 단순했습니다.
src/server.ts
↓
loadConfig()
↓
createServices()
↓
startHttpServer()
↓
POST /mcp
↓
StreamableHTTPServerTransport
↓
createMcpServer()
↓
registerExecTools() / registerFileTools()
↓
Tool Handler
제가 제일 의외였던 건 이 부분입니다.
MCP Server를 하나 띄워놓고 계속 쓰는 게 아니라 현재 stateless HTTP 구현에서는 POST 요청마다 MCP Server와 Transport를 새로 만듭니다.
MCP Server가 직접 shell이나 파일을 만지는 것도 아니었습니다.
Tool을 거쳐 ProcessManager와 FileService로 넘깁니다.
그럼 다음은 ProcessManager입니다.
ChatGPT가 “npm test 실행해줘”라고 했을 때 실제 시스템에서는 어떤 프로세스가 만들어질까?
2부에서는 exec_command가 실제 프로세스를 어떻게 만들고, stdout, stderr, sessionId를 어떻게 관리하는지 더 내려가 보겠습니다.
시리즈
- Codex 토큰을 다 썼다. 그래서 웹을 써봤습니다.
- cokacremote 톺아보기 1부 — MCP Server는 어떻게 시작될까?
- cokacremote 톺아보기 2부 — AI가 명령을 실행한다는 것
- cokacremote 톺아보기 3부 — AI는 왜 apply_patch를 사용할까?
참고
- cokacremote
- 분석 기준 commit:
1bb0f767c4249e8536bcee98341808bebcbbebaa