1부에서는 ChatGPT의 요청이 MCP Tool까지 들어오는 길을 봤고,
2부에서는 exec_command가 실제 OS 프로세스로 내려가는 과정을 봤습니다.
이제 마지막으로 파일 수정입니다.
제가 ChatGPT에게 코드를 수정시켜보면 자주 보이는 방식이 있습니다.
apply_patch입니다.
처음에는 조금 궁금했습니다.
그냥 파일 전체를 다시 쓰면 되는 것 아닌가?
왜 굳이 patch를 만들까?
그래서 이번에는 apply_patch를 까봤습니다.
분석 기준은 앞의 두 글과 같습니다.
repository: https://github.com/lahuman/cokacremote
commit: 1bb0f767c4249e8536bcee98341808bebcbbebaa
date: 2026-08-21
먼저 하나 구분해야 합니다.
제목은 “AI는 왜 apply_patch를 사용할까?”지만,
실제 cokacremote 코드 안에는
이 상황에서는 apply_patch를 사용한다
같은 판단 로직이 없습니다.
cokacremote는 파일 수정용 Tool을 여러 개 제공합니다.
write_file
replace_in_file
apply_patch
mcp-server.ts의 instruction도 파일 작업은 file tool을 사용하라고만 되어 있습니다.
"...and the file tools for direct file operations."
적어도 이 저장소의 서버 코드에는 Tool 선택 규칙이 없습니다.
어떤 Tool을 호출할지는 서버 바깥의 ChatGPT/Agent 쪽에서 결정됩니다.
이건 먼저 구분하고 봐야 합니다.
cokacremote가 하는 일은
apply_patch를 호출받았을 때 그 patch를 어떻게 검증하고 실제 파일에 반영할 것인가?
입니다.
파일 전체를 쓰는 방법도 있습니다.
먼저 write_file을 보면 정말 파일 전체를 쓸 수 있습니다.
Tool 설명도 이렇게 되어 있습니다.
description:
"Create, overwrite, or append to any host file using UTF-8 or base64 content."
mode: "overwrite"라면 결국 writeFile()을 호출합니다.
await writeFile(resolvedPath, data, ...);
예를 들어 500줄짜리 파일에서 한 줄만 바꿔도,
Agent가 write_file을 사용한다면 수정된 500줄 전체를 다시 전달할 수 있습니다.
그렇다고 이 방식이 나쁜 건 아닙니다.
새 파일을 만들거나 파일 전체를 갈아엎어야 한다면 오히려 단순합니다.
그런데 기존 코드 몇 줄만 바꿀 때는 이야기가 조금 달라집니다.
replace_in_file도 있습니다.
문자열 하나만 바꾼다면 이것도 있습니다.
server.registerTool(
"replace_in_file",
기본 동작은 꽤 보수적입니다.
const expected = expectedOccurrences ?? (replaceAll ? occurrences : 1);
if (occurrences !== expected) {
throw new Error(...);
}
기본적으로 바꾸려는 문자열이 정확히 한 번 있는지 확인합니다.
예를 들어
oldText: const port = 3000;
newText: const port = 3001;
처럼 정확한 문자열 하나를 바꾸는 작업이라면 이해하기 쉽습니다.
하지만 여러 파일의 여러 위치를 한 번에 바꾸거나,
주변 문맥을 포함해서 변경하고 싶다면 unified diff가 더 자연스럽습니다.
여기서 apply_patch가 나옵니다.
apply_patch Tool을 봤습니다.
등록 위치는 src/file-tools.ts입니다.
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.",
설명에 답이 거의 적혀 있습니다.
standard unified diff를 git apply로 적용합니다.
입력값은 네 가지 옵션과 patch 본문입니다.
patch
cwd
checkOnly
reverse
threeWay
Schema는 이렇게 되어 있습니다.
inputSchema: {
patch: z.string().min(1).describe("Standard unified diff text."),
cwd: cwdSchema,
checkOnly: z.boolean().default(false),
reverse: z.boolean().default(false),
threeWay: z.boolean().default(false),
},
Handler 자체는 아주 짧습니다.
async ({ patch, cwd, checkOnly, reverse, threeWay }) =>
runTool(() => files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay })),
실제 작업은 다시 FileService로 내려갑니다.
unified diff가 뭘까?
Git을 사용하다 보면 익숙한 형식입니다.
실제 cokacremote integration test에 있는 patch를 보면 이렇습니다.
diff --git a/patch-target.txt b/patch-target.txt
--- a/patch-target.txt
+++ b/patch-target.txt
@@ -1 +1 @@
-old
+new
뜻은 단순합니다.
patch-target.txt 파일에서
old를 지우고
new를 넣는다
입니다.
파일 전체 내용이 아니라 어디가 어떻게 바뀌는지를 표현합니다.
제가 보기에는 patch가 Agent와 잘 맞는 이유도 여기 있습니다.
500줄 파일에서 3줄만 바꾼다면 500줄 전체를 다시 보내는 것보다
-기존 3줄
+바뀐 3줄
을 보내는 쪽이 훨씬 작습니다.
그리고 diff 안에는 변경할 위치 주변의 문맥도 같이 들어갈 수 있습니다.
그래서 원본 파일이 Agent가 읽었을 때와 달라졌다면 적용에 실패할 수도 있습니다.
오히려 이런 실패가 안전장치가 됩니다.
다만 이건 cokacremote가 Agent에게 patch를 선택하도록 강제해서 생기는 장점은 아닙니다.
Agent가 apply_patch를 선택했을 때 얻을 수 있는 특성입니다.
실제 FileService.applyPatch()를 열어봤습니다.
파일은 src/file-service.ts입니다.
일단 cwd부터 처리합니다.
const resolvedCwd = this.resolve(".", cwd);
2부의 exec_command와 비슷하게 기준 디렉터리를 먼저 결정합니다.
FileService.resolve()는 절대경로, ~/, 상대경로를 처리합니다.
resolve(inputPath: string, cwd?: string): string {
const base = cwd
? expandPath(cwd, this.#options.defaultCwd)
: this.#options.defaultCwd;
return expandPath(inputPath, base);
}
여기에는 /workspace 안쪽만 허용하는 containment check 같은 것은 없습니다.
원래 프로젝트 자체가 unrestricted remote development environment를 목표로 하고 있기 때문입니다.
받은 patch는 임시 파일로 만듭니다.
다음 코드가 나옵니다.
const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), "remote-dev-mcp-patch-"),
);
const patchPath = path.join(temporaryDirectory, `${randomUUID()}.patch`);
await writeFile(patchPath, patchText, "utf8");
patch 문자열을 바로 git apply의 stdin으로 넣는 건 아니었습니다.
임시 디렉터리를 하나 만들고
/tmp/remote-dev-mcp-patch-XXXXXX/
그 안에 UUID 이름의 .patch 파일을 만듭니다.
대략 이런 모양입니다.
/tmp/remote-dev-mcp-patch-abc123/
└── 550e8400-e29b-41d4-a716-446655440000.patch
그리고 이 파일 경로를 git apply에 넘깁니다.
실제 명령을 만드는 부분입니다.
제가 제일 보고 싶었던 부분이 여기입니다.
const baseArguments = ["apply", "--unsafe-paths", "--whitespace=nowarn"];
즉 기본 명령은 사실상 이렇게 됩니다.
git apply --unsafe-paths --whitespace=nowarn PATCH_FILE
여기서 --whitespace=nowarn은 whitespace 차이를 무시한다는 뜻은 아닙니다.
Git 문서 기준으로 trailing whitespace 경고를 끄는 옵션입니다. patch의 문맥에서 whitespace 차이를 무시하는 --ignore-whitespace와는 다른 옵션입니다.
그런데 바로 적용하지는 않습니다.
먼저 한 번 검사합니다.
무조건 --check부터 합니다.
코드를 보면 check용 arguments를 따로 만듭니다.
const checkArguments = [...baseArguments, "--check", patchPath];
그리고 먼저 실행합니다.
const checked = await execFileAsync("git", checkArguments, {
cwd: resolvedCwd,
encoding: "utf8",
maxBuffer: this.#options.maxOutputBytes,
});
실제 흐름은 이렇습니다.
git apply --unsafe-paths --whitespace=nowarn --check PATCH_FILE
git apply --check는 파일을 수정하지 않고 patch를 적용할 수 있는지 확인합니다.
그래서 apply_patch는 무작정 파일부터 바꾸지 않습니다.
이 방식은 꽤 마음에 들었습니다.
실제 파일을 건드리기 전에 같은 옵션으로 먼저 검증합니다.
checkOnly면 여기서 끝납니다.
Tool 호출에서
checkOnly: true
를 주면 --check까지만 실행합니다.
if (options.checkOnly) {
return {
cwd: resolvedCwd,
applied: false,
checkOnly: true,
stdout: checked.stdout,
stderr: checked.stderr,
};
}
여기서 applied: false는 실패했다는 뜻이 아닙니다.
검증만 했기 때문에 실제 적용하지 않았다는 뜻입니다.
integration test도 이걸 확인합니다.
먼저 파일 내용을
old
로 만든 다음 checkOnly: true로 호출합니다.
expect(await callOk("apply_patch", {
patch,
cwd: testRoot,
checkOnly: true,
})).toMatchObject({ applied: false, checkOnly: true });
그리고 다시 파일을 읽어서 여전히
old
인지 확인합니다.
검증을 통과하면 실제로 한 번 더 실행합니다.
checkOnly가 아니라면 다음 코드로 갑니다.
const applied = await execFileAsync("git", [...baseArguments, patchPath], {
cwd: resolvedCwd,
encoding: "utf8",
maxBuffer: this.#options.maxOutputBytes,
});
이번에는 --check가 없습니다.
git apply --unsafe-paths --whitespace=nowarn PATCH_FILE
실제 적용입니다.
성공하면
return {
cwd: resolvedCwd,
applied: true,
checkOnly: false,
stdout: applied.stdout,
stderr: applied.stderr,
};
를 반환합니다.
결국 흐름만 줄이면 이 정도입니다.
patch 받기
↓
임시 파일 만들기
↓
git apply --check
↓
성공?
├─ NO → error
└─ YES
├─ checkOnly → 종료
└─ git apply → 적용
실패하면 어떻게 될까?
git apply --check나 실제 git apply가 실패하면 catch로 들어갑니다.
catch (error) {
const execError = error as Error & { stdout?: string; stderr?: string };
throw new Error(
[errorMessage(execError), execError.stdout, execError.stderr]
.filter(Boolean)
.join("\n"),
);
}
git 실행 중 발생한 에러와 stdout, stderr를 합쳐서 다시 throw합니다.
그런데 Tool Handler는 runTool()로 감싸져 있습니다.
export async function runTool(...) {
try {
return successResult(await operation());
} catch (error) {
return errorResult(error);
}
}
그래서 MCP 쪽에는 isError: true인 Tool 결과로 돌아가고, Agent는 git apply에서 나온 에러 메시지를 확인할 수 있습니다.
apply_patch
↓
git apply --check 실패
↓
FileService throw
↓
runTool errorResult
↓
ChatGPT가 에러 확인
여기서 하나 구분해야 합니다.
cokacremote 서버가 실패한 patch를 스스로 고쳐서 재시도하지는 않습니다.
그 다음은 Agent가 결정합니다.
예를 들어 Agent가
1. read_file로 현재 파일을 다시 읽고
2. 바뀐 내용을 확인하고
3. 새로운 patch를 만들고
4. apply_patch를 다시 호출
할 수 있습니다.
실제로 AI 개발 도구를 쓰다 보면 이런 흐름을 자주 보게 됩니다.
다만 이 반복 흐름은 cokacremote 내부 구현이 아닙니다.
MCP Tool의 실패 결과를 받은 Agent가 할 수 있는 작업 흐름입니다.
왜 patch가 Agent에게 잘 맞을까?
여기부터는 cokacremote 소스 자체라기보다 Agent 작업 방식에 대한 이야기입니다.
코드를 보고 실제로 써보니 patch가 잘 맞는 이유가 몇 가지 보였습니다.
가장 먼저 수정량이 작습니다.
파일 전체를 다시 보내지 않고 바뀌는 부분만 전달할 수 있습니다.
그리고 기존 문맥으로 검증할 수 있습니다.
patch의 주변 코드가 현재 파일과 맞지 않으면 git apply --check에서 실패할 수 있습니다.
또 여러 파일 변경을 하나의 diff로 표현할 수도 있습니다.
unified diff 자체가 여러 파일의 변경을 담을 수 있기 때문에 관련 수정들을 하나의 patch로 묶는 것도 가능합니다.
반대로 무조건 patch가 좋은 것도 아닙니다.
새 파일 전체를 만드는 작업이라면 write_file이 더 단순할 수 있고,
정확한 문자열 하나만 바꾸는 작업이라면 replace_in_file이 더 이해하기 쉬울 수 있습니다.
그래서 상황에 따라 Tool을 나눠 쓸 수 있습니다.
새 파일 / 전체 교체
→ write_file
정확한 문자열 교체
→ replace_in_file
문맥을 가진 여러 줄 변경
→ apply_patch
이건 cokacremote에 박혀 있는 선택 규칙은 아닙니다.
제가 Tool의 동작을 기준으로 정리한 사용 관점입니다.
reverse도 있습니다.
apply_patch 입력값에는
reverse: true
도 있습니다.
이 값이 들어오면
if (options.reverse) {
baseArguments.push("--reverse");
}
가 실행됩니다.
실제 명령은
git apply --unsafe-paths --whitespace=nowarn --reverse PATCH_FILE
형태가 됩니다.
말 그대로 patch를 반대로 적용합니다.
integration test에서도
old → new
patch를 먼저 적용한 다음
reverse: true
로 다시 적용해서 파일이 old로 돌아오는지 확인합니다.
threeWay도 있습니다.
다음은
threeWay: true
입니다.
코드는
if (options.threeWay) {
baseArguments.push("--3way");
}
입니다.
즉
git apply --3way ...
가 됩니다.
여기는 조건이 하나 있습니다.
Git 공식 문서를 보면 --3way는 patch에 원본 blob을 식별할 정보가 있고, 그 blob을 로컬에서 찾을 수 있을 때 three-way merge를 시도합니다. 아무 unified diff에나 항상 three-way merge가 되는 것은 아닙니다.
그리고 --3way는 --cached를 쓰는 경우를 제외하면 --index를 암묵적으로 사용합니다. Git 문서상 --unsafe-paths는 --index나 --cached와 함께 사용할 때는 효과가 없습니다.
코드에서는 --unsafe-paths와 --3way를 모두 arguments에 넣지만, 실제 의미는 Git 쪽 규칙을 따르는 셈입니다.
프로젝트 integration test에서는 실제 Git 저장소를 하나 만든 뒤 이 옵션까지 테스트합니다.
await callOk("exec_command", {
cmd: "git init -q && git config user.email e2e@example.invalid && git config user.name E2E && git add value.txt && git commit -qm base",
workdir: path.join(testRoot, "three-way"),
});
그 다음 파일을 수정하고
git diff --binary -- value.txt
로 실제 patch를 만듭니다.
파일을 원상복구한 뒤 그 patch를
threeWay: true
로 apply_patch에 넘깁니다.
마지막에 파일 내용이 의도한 값으로 바뀌었는지 확인합니다.
테스트 흐름도 실제 사용할 때와 꽤 비슷합니다.
참고로 기본 git apply는 Git 저장소가 아니어도 현재 작업 디렉터리의 파일에 patch를 적용할 수 있습니다. 반면 threeWay 동작을 제대로 검증하려면 원본 blob 정보가 들어 있는 patch와 Git 저장소 문맥이 필요해서, 테스트에서는 일부러 저장소를 만들어 확인합니다.
그런데 --unsafe-paths는 왜 있을까?
이건 그냥 넘어가기 어려웠습니다.
기본 arguments에 항상 들어가 있습니다.
const baseArguments = ["apply", "--unsafe-paths", "--whitespace=nowarn"];
git apply -h에서 --unsafe-paths 설명은 이렇습니다.
accept a patch that touches outside the working area
작업 영역 밖을 건드리는 patch도 허용한다는 뜻입니다.
cokacremote의 Tool 설명도 아예
Paths are unrestricted and --unsafe-paths is enabled.
라고 적어두었습니다.
README에도 apply_patch가 host의 git apply --unsafe-paths를 사용한다고 명시되어 있습니다.
이름 그대로 주의할 옵션입니다.
정말 바깥 파일도 수정될까?
그래서 격리된 /tmp에서 직접 해봤습니다. 이번 검증 환경의 Git은 2.39.5였습니다.
구조를 이렇게 만들었습니다.
/tmp/cokacremote-patch-check-XXXXXX/
├── outside.txt
└── work/
work를 현재 작업 디렉터리로 두고 patch에는
diff --git a/../outside.txt b/../outside.txt
--- a/../outside.txt
+++ b/../outside.txt
@@ -1 +1 @@
-old
+new
를 넣었습니다.
먼저 일반 git apply --check를 실행했습니다.
결과는
exit=128
error: invalid path '../outside.txt'
였습니다.
바로 거부됩니다.
이번에는 --unsafe-paths를 붙였습니다.
git apply --unsafe-paths --check
검증이 성공했습니다.
실제로 적용한 뒤 outside.txt를 확인하니
new
으로 바뀌었습니다.
--unsafe-paths는 이름만 그런 게 아니었습니다.
실제로 cwd 바깥 경로를 건드릴 수 있습니다.
이게 무조건 취약점이라는 뜻은 아닙니다.
다만 여기서는 프로젝트 목적도 같이 봐야 합니다.
cokacremote 자체가 서버 instruction에서부터
unrestricted remote development environment
라고 밝히고 있습니다.
파일 Tool도 absolute path를 허용하고,
exec_command 역시 MCP 서버 프로세스의 OS 권한으로 명령을 실행합니다.
apply_patch만 특별히 sandbox 안에 가두는 구조가 아닙니다.
원래 설계 자체가 신뢰된 사용자에게 원격 개발환경 전체를 열어주는 형태입니다.
그래서 더 중요한 건 --unsafe-paths 자체보다
이 MCP 서버에 누가 접근할 수 있느냐입니다.
1부에서 인증 부분을 본 이유도 여기와 연결됩니다.
인증 없이 인터넷에 그대로 노출하면 안 되는 종류의 도구입니다.
제 Docker 환경에서는 조금 다르게 보입니다.
제가 사용하는 구조는 이렇습니다.
2부에서 봤듯이 cokacremote의 Node.js 프로세스는 Docker 컨테이너 안에서 실행됩니다.
따라서 apply_patch가 실행하는
git apply ...
도 컨테이너 안에서 실행됩니다.
여기서 /workspace를 Mac의 프로젝트 디렉터리에 volume으로 연결해두면,
/workspace 아래 파일 수정은 결국 Mac의 실제 프로젝트 파일에도 반영됩니다.
반대로 컨테이너에서 보이지 않는 Mac 경로까지 마음대로 접근하는 건 아닙니다.
Docker가 어떤 경로를 mount했고 컨테이너 프로세스에 어떤 권한을 줬는지가 경계가 됩니다.
즉 제가 사용하는 환경에서는
MCP 인증
+
Docker filesystem / volume 경계
+
컨테이너 사용자 권한
도 같이 봐야 합니다.
임시 patch 파일은 마지막에 지웁니다.
applyPatch() 마지막에는 finally가 있습니다.
finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
성공하든 실패하든 임시 patch 디렉터리를 정리합니다.
그래서 정상 흐름에서는 /tmp/remote-dev-mcp-patch-* 파일이 계속 쌓이지 않습니다.
이런 건 확인해두면 마음이 편합니다.
실제 테스트도 돌려봤습니다.
이번에도 테스트를 직접 돌렸습니다.
test/file-service.test.ts
test/all-tools.integration.test.ts
결과는
Test Files 2 passed (2)
Tests 13 passed (13)
였습니다.
여기에는 apply_patch 관련해서
checkOnly 검증
실제 patch 적용
reverse 적용
잘못된 patch의 error 처리
--3way 적용
MCP를 통한 Tool 호출
이 포함되어 있습니다.
그리고 --unsafe-paths의 작업 디렉터리 밖 수정은 프로젝트 테스트와 별도로 격리된 /tmp에서 직접 확인했습니다.
결국 왜 apply_patch일까?
다시 처음 질문으로 돌아가봤습니다.
파일 전체를 다시 쓰면 되는데 왜 AI는 patch를 사용할까?
cokacremote만 보고 “AI가 반드시 patch를 선택하는 이유”까지 알 수는 없습니다.
그 선택은 Agent 쪽에 있기 때문입니다.
대신 apply_patch를 까보니 코드 수정에 왜 잘 맞는지는 보였습니다.
전체 파일을 덮어쓰지 않고 변경분만 전달할 수 있고,
적용 전에 git apply --check로 현재 파일과 맞는지 확인할 수 있고,
실패하면 에러를 받아 현재 상태를 다시 읽고 새 patch를 만들 수 있습니다.
AI Agent가
읽기
→ 수정
→ 실행
→ 확인
→ 실패하면 다시 읽기
를 반복하는 구조와 꽤 잘 맞습니다.
그리고 cokacremote는 그 반복에서 필요한 도구를 제공합니다.
read_file
apply_patch
exec_command
read_process
1부부터 여기까지 보고 나니 처음의 “ChatGPT에 내 개발환경을 연결했다”는 말도 조금 다르게 보입니다.
실제로는
HTTP 요청을 MCP Tool로 바꾸고
↓
Tool이 OS process와 filesystem으로 내려가고
↓
결과를 다시 Agent에게 돌려주고
↓
Agent가 다음 행동을 결정하는 구조
였습니다.
까보니 특별한 마법은 없었습니다.
익숙한 HTTP, process, filesystem, Git이 이어져 있을 뿐입니다.
그런데 이걸 Agent가 반복해서 사용할 수 있게 연결해두니 개발 방식이 꽤 달라졌습니다.
처음 cokacremote를 Docker에 띄울 때는 그냥 한번 써보자는 생각이었는데,
코드까지 열어보니 왜 이걸로 실제 개발이 되는지 이제 좀 알겠습니다.
딸칵질 뒤에서 무슨 일이 일어나는지 드디어 다 봤습니다.
시리즈
- Codex 토큰을 다 썼다. 그래서 웹을 써봤습니다.
- cokacremote 톺아보기 1부 — MCP Server는 어떻게 시작될까?
- cokacremote 톺아보기 2부 — AI가 명령을 실행한다는 것
- cokacremote 톺아보기 3부 — AI는 왜 apply_patch를 사용할까?
참고
- cokacremote
- git apply documentation
- 분석 기준 commit:
1bb0f767c4249e8536bcee98341808bebcbbebaa