iOS 앱과 함께 동작하는 MCP 서버: 두 개의 에이전트 생태계, 하나의 리스트
제 SwiftUI 쇼핑 리스트 앱인 Get Bananas는 iOS, macOS, watchOS, visionOS에서 실행됩니다.1 또한 Claude Desktop 안에서는 .mcpb MCP 확장으로 동작하며 다섯 개의 도구를 노출합니다: get_shopping_list, add_item, remove_item, update_item, update_shopping_list.2
Claude에게 “내 리스트에 바나나, 우유, 빵을 추가해 줘”라고 말하면, Claude는 add_item을 세 번 호출하고, 다음에 제가 폰에서 앱을 열면 항목들이 거기 있습니다. 서버 없음. 계정 없음. API 키 없음. 이 다리는 단 하나의 JSON 파일에, 제가 v1.0을 출시한 뒤 추가해야 했던 다섯 겹의 루프 방지 장치를 더한 것입니다. 그 v1.0은 3분 만에 자기 자신을 4MB 파일로 부풀렸거든요.
흥미로운 질문은 “어떻게”입니다. SwiftData는 Apple 플랫폼 런타임 전용이며 Node.js 프로세스에서는 읽을 수 없습니다.3 네이티브 CloudKit 프레임워크는 일치하는 com.apple.developer.icloud-services 자격(entitlement)과 Apple Developer 팀 식별자를 요구하는데, Claude Desktop의 MCP 서브프로세스에는 둘 다 없습니다. 그래서 제가 서명한 앱과 달리 CKContainer를 사용할 수 없습니다. CloudKit Web Services가 존재하긴 하지만, 그것을 사용하려면 데스크톱 프로세스와 Apple 서버 사이에 별도의 토큰/인증 다리를 유지해야 합니다.4 그러니 명백한 길들은 모두 막혀 있습니다.
제가 택한 길은 더 오래되고 더 이상한 길입니다. Get Bananas 앱과 그 MCP 서버는 iCloud Drive 안의 JSON 파일을 통해 상태를 공유합니다. Swift 앱은 앱 내부 영속성을 위해 SwiftData 모델을 유지하고, 변경이 있을 때마다 NSFileCoordinator를 통해 자신의 iCloud Drive 컨테이너로 BananaList.json 파일을 내보냅니다. Node.js MCP 서버는 5초짜리 배타적 파일 잠금, 오래된 잠금 감지, 원자적 임시 파일-rename 쓰기를 사용해 같은 파일을 읽고 씁니다. iCloud Drive가 디바이스 간 동기화를 처리합니다. 오늘은 Claude Desktop이 Mac에서 같은 진실의 원천(source of truth)을 읽고 씁니다. 다음 표면은 Apple Intelligence를 위한 App Intents 어댑터이며, 같은 파일을 대상으로 합니다.
이 글은 그 구성이 왜 작동하는지, 무엇을 비용으로 치르는지, 어디에서 무너지는지에 관한 이야기입니다.
TL;DR
- Get Bananas는 함께 출하되는 MCP 서버를 통해 쇼핑 리스트를 Claude Desktop에 노출합니다. 같은 파일 형식이 다음에는 Apple Intelligence용 App Intents 어댑터를 지원할 것입니다.
- 통합 기반은 iCloud Drive와 JSON 파일입니다. CloudKit도, 서버도, 서비스도 아닙니다.
- Swift 앱: 앱 내부 속도를 위한 SwiftData
@Model ShoppingItem; 이식성을 위한 iCloud Drive JSON 내보내기. - MCP 서버: 575줄의 Node.js, 오래된 잠금 감지가 포함된 파일 잠금, Claude Desktop의
.mcpb번들 안에서 실행됨. - 트레이드오프: 파일 기반 JSON는 CloudKit보다 동기화가 느리고 병합 충돌 위험이 있지만, 파일을 읽을 수 있는 모든 에이전트 생태계에서 작동합니다.

Anthropic가 문서화한 Model Context Protocol 아키텍처. MCP 호스트(Claude Desktop)가 하나 이상의 MCP 서버(이 글에서는 get-bananas.mcpb)에 연결하며, 각 서버는 호스트가 호출할 수 있는 도구, 리소스, 프롬프트를 노출합니다. 출처: modelcontextprotocol.io.11
한 페이지로 보는 아키텍처
┌─────────────────────────────────────────────────────────┐
│ Get Bananas iOS app │
│ SwiftUI views → SwiftData @Model ShoppingItem │
│ ↓ (debounced 0.5s, atomic write) │
│ iCloudBackupManager.swift │
│ ↓ │
│ ~/Library/Mobile Documents/.../BananaList.json │
└──────────────────────────┬──────────────────────────────┘
│
iCloud Drive sync
│
┌──────────────────────────┴──────────────────────────────┐
│ Claude Desktop on Mac │
│ ↑ │
│ get-bananas.mcpb (Node.js MCP server) │
│ - acquireLock() with 5s timeout │
│ - readShoppingList() / writeShoppingList() │
│ - 5 tools: get/add/remove/update/replace │
│ ↑ │
│ JSON-RPC (stdio) ← Claude │
└─────────────────────────────────────────────────────────┘
두 개의 표면. 하나의 파일. 다리 전체가 그 파일입니다.
Swift 쪽: 속도를 위한 SwiftData, 이식성을 위한 JSON
앱 내에서 쇼핑 리스트는 SwiftData @Model입니다. 실제 프로덕션 코드입니다:5
@Model
final class ShoppingItem {
@Attribute(.unique) var id: UUID
var name: String
var amount: String
var section: String
var isChecked: Bool
var isOptional: Bool
var sortOrder: Int
var lastModified: Date?
}
이것이 인메모리 진실입니다. 모든 키 입력, 모든 체크박스 탭, 모든 섹션 변경이 SwiftData에 기록됩니다. SwiftData가 SwiftUI 뷰를 구동합니다. 앱이 네이티브처럼 느껴지는 이유는 실제로 네이티브이기 때문입니다. 백업 트리거는 해시 기반입니다. .onChange(of: computeItemsHash()) 감시자는 항목의 id, name, amount, section, checked, optional 상태가 바뀔 때만 발화하며, 순수한 노옵(no-op) 편집에는 절대 발화하지 않습니다.
여기서의 트릭은 SwiftData가 프로세스 간 진실이 아니라는 점입니다. 그것은 프로세스 간 캐시입니다. 모든 변경은 500ms 동안 디바운스된 다음, Apple의 좌표화된-쓰기 API를 통해 앱의 iCloud Drive 컨테이너로 JSON 파일을 씁니다:6
// iCloudBackupManager.swift, paraphrased
private let fileName = "BananaList.json"
static let backupDebounceDelay: TimeInterval = 0.5
static let ignoreBackupAfterSyncWindow: TimeInterval = 5.0
static let maxRetries = 3
// Real pre-write content check + NSFileCoordinator
let coordinator = NSFileCoordinator(filePresenter: nil)
coordinator.coordinate(writingItemAt: backupURL, options: [], error: &err) { url in
try jsonString.write(to: url, atomically: true, encoding: .utf8)
}
NSFileCoordinator는 다른 프로세스(그리고 iCloud Drive의 데몬)가 동시에 읽을 수 있는 파일을 쓰는 공식 지원 방식입니다.16 그 쓰기 직전에, 매니저는 기존 파일을 읽고 JSON가 바이트 단위로 일치하면 쓰기를 완전히 건너뜁니다. 이렇게 하면 SwiftData 변경 옵저버가 노옵 편집에 발화할 때마다 발생하는 불필요한 iCloud Drive 변동을 줄일 수 있습니다. restore에서는 매니저가 지수 백오프(1s, 2s, 4s, 총 7초 예산)로 최대 세 번까지 재시도합니다. NSMetadataQuery는 iCloud Drive가 새 바이트를 실제로 다운로드하기 전에 파일 변경을 보고하기 때문입니다.6
파일의 Codable 형태는 의도적으로 관대합니다. ShoppingListExport는 누락된 모든 필드에 기본값을 적용해 디코딩하고 이름이 빈 항목을 걸러냅니다:7
struct ShoppingListExport: Codable {
var sections: [String]
var items: [ShoppingItemData]
struct ShoppingItemData: Codable {
var id: UUID
var name: String
var amount: String
var section: String
var optional: Bool
var checked: Bool
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
self.amount = try container.decodeIfPresent(String.self, forKey: .amount) ?? ""
// ...
}
var isValid: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
}
방어적인 디코더는 의도된 것입니다. 다음에 JSON를 쓰는 주체(MCP 서버, 미래의 단축어, 수동 붙여넣기 등)가 어떤 것이 되든, 필드 하나는 반드시 잊어버립니다. Swift 쪽이 그것을 흡수합니다. 공유 파일 형식이 계약이며, Swift 디코더가 너그러운 쪽입니다.

Node 쪽: 같은 파일을 읽는 575줄짜리 MCP 서버
MCP 서버는 mcp-extension/server/index.js에 있으며, Claude Desktop의 확장 시스템을 위한 get-bananas.mcpb로 배포됩니다. 그것은 macOS 호스트에서 같은 iCloud Drive 파일을 엽니다:2
const ICLOUD_FILE_PATH = path.join(
os.homedir(),
"Library/Mobile Documents/iCloud~com~941apps~Banana-List/Documents/BananaList.json"
);
다섯 개의 도구가 있습니다: 순수 읽기 하나(get_shopping_list), 항목 단위의 read-modify-write 도구 세 개(add_item, remove_item, update_item), 그리고 먼저 읽지 않고 쓰는 일괄 교체 도구 하나(update_shopping_list). MCP 서버는 또한 리소스 API를 선호하는 클라이언트를 위해 그 파일을 별도의 읽기 전용 Resource로도 노출합니다. 모든 쓰기는 오래된 잠금 복구가 포함된 파일 잠금을 거칩니다:
const LOCK_FILE_PATH = ICLOUD_FILE_PATH + ".lock";
const LOCK_TIMEOUT_MS = 5000;
async function acquireLock() {
const startTime = Date.now();
while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
try {
fs.writeFileSync(LOCK_FILE_PATH, String(process.pid), { flag: 'wx' });
return true;
} catch (err) {
if (err.code === 'EEXIST') {
const stat = fs.statSync(LOCK_FILE_PATH);
if (Date.now() - stat.mtimeMs > LOCK_TIMEOUT_MS) {
fs.unlinkSync(LOCK_FILE_PATH); // stale lock recovery
continue;
}
await new Promise(resolve => setTimeout(resolve, 50));
} else {
throw err;
}
}
}
throw new Error("Could not acquire file lock; please try again.");
}
이 잠금 패턴은 Node.js보다 오래되었습니다. 'wx' 플래그를 단 fs.writeFileSync는 O_EXCL | O_CREAT의 크로스 플랫폼 버전입니다. 잠금 파일이 존재하고 5초 이상 오래되었다면, 서버는 이전 보유자가 크래시했다고 가정하고 잠금을 회수합니다. 존재하지만 신선하다면, 서버는 50ms 기다린 뒤 재시도합니다. 총 5초가 지나면 포기합니다.8
이 잠금은 Node 쪽 작성자들끼리만 동기화합니다(첫 번째가 쓰는 도중 두 번째 MCP 호출이 들어오는 경우 등). Swift 앱과는 협조하지 않습니다. Swift 앱은 NSFileCoordinator와 String.write(atomically:)를 통해 쓰며 BananaList.json.lock은 절대 건드리지 않습니다. 진짜 Swift/Node 겹침은 더 약한 두 가지 메커니즘에 맡겨집니다: Swift 앱은 쓰기 전 500ms 디바운스하고, MCP 쓰기는 사용자 프롬프트에서만 발생하며, 남는 충돌은 파일 단위에서 iCloud Drive의 last-write-wins 해결로 떨어집니다.
쓰기 자체는 원자적 임시 파일-그다음-rename 패턴을 사용하며, 그 사이에 JSON 파싱 검사를 둡니다:
const tempPath = ICLOUD_FILE_PATH + ".tmp." + process.pid;
fs.writeFileSync(tempPath, jsonString, "utf8");
// Verify the temp file is valid JSON before renaming
JSON.parse(fs.readFileSync(tempPath, "utf8"));
// Atomic rename - either the new file or the old file, never partial
fs.renameSync(tempPath, ICLOUD_FILE_PATH);
같은 파일 시스템에서의 fs.renameSync는 POSIX 원자적입니다. 다른 프로세스의 리더는 옛 파일이나 새 파일 중 하나를 보게 되며, 절반만 쓰인 바이트는 결코 보지 못합니다.17 임시 경로를 process.pid에 묶어두면, 두 개의 MCP 서버 인스턴스(드물지만, 사용자가 Claude Desktop을 재시작 없이 재설치하면 가능합니다)가 서로의 임시 파일을 덮어쓰지 못하게 합니다. 쓰는 도중의 JSON.parse는 편집증적 단계입니다. 직렬화 자체가 잘못된 JSON를 만들어 냈다면, 함수는 rename 이전에 중단되어 정식 파일을 그대로 둡니다.
왜 CloudKit이 아니라 iCloud Drive인가
이 아키텍처가 작동하게 만드는 선택은, 프로세스 간 진실로 CloudKit(레코드 기반) 대신 iCloud Drive(파일 기반)를 사용한 것입니다. CloudKit은 Apple이 앱-대-앱 동기화를 위해 권장하는 방식입니다. 더 높은 수준의 충돌 해결, 서버 측 푸시, 존(zone) 기반 파티셔닝을 제공합니다.9 네이티브 CKContainer API는 Apple 플랫폼 전용이고 자격(entitlement)으로 잠겨 있어, Claude Desktop 서브프로세스는 제 서명된 앱처럼 그것을 사용할 수 없습니다. Apple은 비-Apple-플랫폼 클라이언트를 위해 CloudKit Web Services를 발행하긴 하지만, 그것을 사용하려면 서버-투-서버 토큰을 발급하고 MCP 서버에 배선하며 별도 인증 다리를 유지해야 합니다. 불가능하지는 않지만, 쇼핑 리스트치고는 상당한 인프라입니다.4
MCP 서버는 macOS에서 Claude의 서브프로세스로서 샌드박스 없이 실행됩니다. Apple Developer 서명 체인이 없고, 제 앱의 CloudKit 컨테이너와 일치하는 팀 식별자가 없으며, 구성된 CloudKit Web Services 토큰도 없습니다.
반면 iCloud Drive는 자신을 일반 파일 시스템 위치로 노출합니다. 앱 쪽에서 Apple이 지원하는 API는 FileManager.url(forUbiquityContainerIdentifier:)이며,14 macOS에서 Get Bananas의 해석된 위치는 ~/Library/Mobile Documents/iCloud~com~941apps~Banana-List/Documents/BananaList.json입니다. 그 경로는 iCloud Drive가 컨테이너를 표면화하는 위치의 macOS 특화 구현 디테일이지만, 같은 Mac에서 실행되는 Claude Desktop 입장에서는 그저 파일일 뿐입니다. 사용자 홈 디렉터리에 읽기 권한이 있는 어떤 프로세스든 그것을 읽고 쓸 수 있습니다. 미래의 단축어, 미래의 SwiftBar 플러그인, 사용자가 로컬에서 실행하는 미래의 llama.cpp 스크립트도 마찬가지입니다. 파일을 읽을 수 있는 것이라면 무엇이든 통합할 수 있습니다.
비용은 iCloud Drive의 동기화가 CloudKit보다 느리고(서브-초가 아니라 초 단위) 충돌 의미론이 더 약하다는 점입니다(레코드 단위 병합이 아니라 파일 단위의 last-write-wins). 항목이 30개 정도인 쇼핑 리스트라면 두 비용 모두 의미가 없습니다. 1만 행과 동시 편집자가 있는 고-쓰기-볼륨 앱이라면 두 비용 모두 지배적이 됐을 것입니다.
다섯 겹의 루프 방지
Swift 쪽에서 가장 까다로운 코드는 루프 방지입니다. 이것이 없다면: MCP 서버가 JSON를 쓰면, iCloud Drive가 그것을 iOS로 동기화하고, iOS 앱의 NSMetadataQuery가 변경을 감지하며, 앱은 JSON를 SwiftData로 다시 가져옵니다. 그 가져오기는 SwiftData 변경 옵저버를 트리거하고, 변경 옵저버는 디바운스된 백업을 발화시키며, 디바운스된 백업은 JSON를 쓰고, iCloud Drive는 그것을 다시 동기화합니다. 저는 v1.0에 그 순진한 버전을 출시했고, 테스트 도중 30개 항목짜리 쇼핑 리스트가 3분 만에 4MB로 부풀어 오르는 것을 지켜보았습니다.10
출시된 버전은 하나가 아니라 다섯 개의 누적된 가드를 사용합니다. 각각 서로 다른 타이밍 엣지 케이스를 막습니다:
// Layer 1: Thread-safe sync counter (re-entrant guard)
private let syncLock = NSLock()
private var _syncCount: Int = 0
var isSyncing: Bool { syncCount > 0 }
// Layers 1 + 2: shouldSkipBackup gates outbound writes
var shouldSkipBackup: Bool {
if isSyncing { return true } // Layer 1
if let lastSync = lastSyncTime,
Date().timeIntervalSince(lastSync) < Constants.ignoreBackupAfterSyncWindow {
return true // Layer 2
}
return false
}
// Layer 3 (in NSMetadataQuery handler): drop changes within 2s of our own backup
if let lastBackup = lastBackupTime,
Date().timeIntervalSince(lastBackup) < Constants.ignoreChangesWindow {
return
}
// Layer 4: exact mod-date match = our own backup coming back via iCloud
if let lastBackupMod = lastBackupModificationDate, modDate == lastBackupMod {
return
}
// Layer 5: monotonic mod-date guard against re-processing the same version
if let lastSynced = lastSyncedModificationDate, modDate <= lastSynced {
return
}
| 계층 | 위치 | 무엇을 막는가 |
|---|---|---|
| 1. 싱크 카운터 > 0 | 아웃바운드 쓰기 경로 | iCloud로부터의 동기화가 진행 중인 동안 트리거되는 재진입(re-entrant) 쓰기 |
| 2. 동기화 후 5초 윈도우 | 아웃바운드 쓰기 경로 | 임포트가 안정된 이후에 SwiftData가 발화시키는 지연된 @Model onChange 콜백 |
| 3. 백업 후 2초 윈도우 | 인바운드 NSMetadataQuery 핸들러 |
앱 자신의 쓰기 직후 발화하는 로컬 파일 시스템 이벤트 |
| 4. 수정일 정확 일치 | 인바운드 핸들러 | iCloud Drive가 디바이스를 거쳐 우리 자신의 백업을 되돌려 보내는 경우 |
| 5. 단조 증가 수정일 | 인바운드 핸들러 | NSMetadataQuery가 단일 변경에 대해 DidUpdate와 DidFinishGathering을 모두 발화시키는 경우 |
처음 두 계층은 아웃바운드 쓰기 경로를 통제합니다: 지금 iCloud로 내보내야 하는가? 나머지 세 계층은 인바운드 NSMetadataQuery 핸들러를 통제합니다: 이 변경을 SwiftData에 가져와야 하는가? 어느 한쪽만으로는 부족합니다. 단일 동기화 왕복은 어느 이벤트가 먼저 발화하느냐에 따라 양쪽의 가드를 모두 통과할 수 있으므로, 각 경로는 자신의 방어가 필요합니다.
이 교훈은 “두 작성자가 공유하는 파일” 아키텍처 일반에 적용됩니다: 수정 시간 기반 변경 감지는 필요하지만 충분하지 않습니다. “내가 일으킨 쓰기”에 대한 안정적 정체성이 동기화 계층을 적어도 한 번 왕복해도 살아남아야 합니다. iCloud Drive가 가장 가깝게 제공하는 것은 쓰는 순간의 파일 수정일입니다. 그것을 붙들고 있다가, 돌아오는 길에 비교하세요.
다시 만든다면 다르게 했을 것들
이걸 출시하면서 얻은 네 가지 교훈입니다.
Swift 쪽의 방어적 Codable은 값을 합니다. MCP 서버는 세 번 다시 작성됐습니다. 매 재작성마다 적어도 한 번은 필드를 설정하는 것을 잊었습니다. Swift 디코더는 모든 변형을 흡수했고 앱은 한 번도 크래시하지 않았습니다. 처음부터 다시 한다면 더 많은 필드를 “필수”가 아니라 “기본값으로 디코딩”으로 밀어넣을 것입니다. 두 작성자 사이의 계약은 설계상 깨지기 쉽습니다.7
잠금 타임아웃은 mtime만이 아니라 내용도 인지해야 합니다. 5초는 짧습니다. 사용자의 Mac이 느린 Wi-Fi에 있거나, iOS 디바이스가 긴 백그라운드 끝에 복원되는 중이라면, BananaList.json.lock의 iCloud Drive 동기화가 전파되는 데 5초보다 더 걸릴 수 있습니다. 그러면 MCP 서버는 실제로는 여전히 보유 중인 잠금을 오래된 잠금으로 봅니다. 해결책은 잠금 파일 안에 쓰인 PID에 오래된-잠금 검사를 거는 것입니다: kill(pid, 0)이 여전히 실행 중인 프로세스를 보고하면, mtime이 아무리 오래되어 보여도 잠금을 깨지 않습니다. 현재 코드는 PID를 쓰지만 그것을 다시 읽지 않습니다.
update_shopping_list 도구는 실수였습니다. 그것은 리스트 전체를 교체합니다. Claude Desktop은 단일 항목 연산이면 충분할 때도 가끔 그것을 호출하며, 그러면 사용자 리스트의 적지 않은 부분이 사라집니다. 항목 단위 도구 네 개(get, add, remove, update)만 출시하고 Claude가 그것들을 조합하도록 강제했어야 했습니다. MCP 프로토콜의 destructiveHint: true 어노테이션이 도구를 파괴적이라고 표시하긴 하지만,11 Claude는 호출하기 전에 그것을 사용자에게 항상 표면화하지는 않습니다. 일괄 교체 도구는 LLM에게는 편리하고 사용자에게는 위험합니다. 프로토콜 계층에 가드레일이 있다는 사실이 풋건(foot-gun)을 출시하지 않는 것을 대신해주지는 못합니다.
공유되는 JSON 내보내기에는 버전 필드가 필요합니다. ShoppingListExport는 관대한 기본값으로 디코딩하는데, 이것은 제가 필드를 추가하는 게 아니라 이름을 바꾸는 날까지만 작동합니다. JSON의 맨 위에 schemaVersion: 1이 있다면, 어느 쪽이든 미래의 호환성 깨짐을 감지하고 잘못된 모델을 조용히 만들어 내는 대신 읽기를 거부할 수 있습니다. 마이그레이션은 여전히 수동이겠지만, 적어도 실패 모드는 조용한 데이터 손실이 아니라 큰 소리로 드러날 것입니다.
이 패턴을 쓰지 말아야 할 때
거절도 설계의 일부입니다.
데이터가 규제를 받는다면(건강, 금융, 컴플라이언스 보유 정책이 있는 모든 것), iCloud Drive의 사용자-제어 파일 시스템은 잘못된 기반입니다. CloudKit에는 로깅과 감사 추적이 있고, 사용자가 읽을 수 있는 JSON 파일에는 없습니다.
프로세스 간 지연 예산이 서브-초라면 iCloud Drive는 그것을 만족하지 못합니다. 제 테스트에서 iCloud Drive 동기화는 건강한 연결에서도 보통 서브-초가 아니라 초 단위로 걸렸습니다. Apple은 더 엄격한 SLA를 발표하지 않으며, 느린 네트워크에서는 더 길어집니다. CloudKit의 푸시 기반 전달이 레코드 단위 업데이트에서는 의미 있게 더 빠릅니다. 실시간 협업 제품에는 CloudKit(또는 전용 동기화 서버)이 필요합니다.
스키마가 빠르게 진화한다면, 기본값-있는-Codable 패턴은 부채를 누적시킵니다. 새 필드마다 “기존 파일을 위한 기본값” 결정이 필요한데, 이 결정은 빨리 낡습니다. JSON 파일 동기화는 대부분 추가형으로 변하는 안정적 스키마에 가장 적합합니다.
여러 에이전트 생태계에서 도달 가능한 앱이 되고자 할 때
이 패턴은 반복하기 쉬울 만큼 단순합니다. 세 조각입니다:
- 앱 내 영속성을 위한 SwiftData
@Model. UI를 구동하고, 빠르고, 네이티브. - 변경이 디바운스되면 iCloud Drive에 쓰는 Codable JSON 내보내기. 방어적 디코더. 안정적 스키마. 공유 파일이 계약.
- 각 에이전트 생태계를 위한 작은 어댑터. 파일 잠금으로 같은 파일을 읽고 씁니다. Claude Desktop을 위한 Node.js. Apple Intelligence를 위한 미래의 App Intent + AppEntity. 다음에 무엇이 출시되든 그것을 위한 미래의 셸 스크립트.
이 패턴이 이식 가능한 이유는 통합 기반이 파일 시스템이기 때문입니다. 오늘 존재하는 모든 에이전트 런타임(Claude Desktop, Cursor, Goose, Cline)과 내년에 출시될 대부분이 파일을 읽을 수 있습니다.11 CloudKit은 그렇지 못합니다. 네이티브 동기화 엔진도 그렇지 못합니다. LLM 생태계 전반에 도달하는 것이 목표일 때는 최소 공통 분모가 이깁니다.
Anthropic와 Apple은 에이전트가 어떤 모습이어야 하는지에 대해 의견이 갈립니다. App Intents는 그것이 Apple Intelligence가 온-디바이스에서 해석하는 타입화된 Swift 선언이라고 말합니다. MCP는 그것이 어떤 LLM이든 호출할 수 있는 도구 목록을 가진 JSON-RPC 서버라고 말합니다. 둘 다 자기 생태계 안에서는 옳습니다. Get Bananas는 둘 중 어느 것도 진실의 원천으로 다루지 않고, 파일 시스템이 중재하도록 합니다.12
다음에 두 개의 에이전트 표면을 갖고 싶은 앱을 출시할 때는, 엔티티 모델보다 파일 형식부터 시작할 것입니다.
FAQ
.mcpb는 무엇이며 어떻게 작동하나요?
.mcpb는 Claude Desktop을 위한 Anthropic의 MCP 확장 번들 형식입니다. 도구를 설명하는 manifest.json, MCP 서버 진입점(Node.js, Python 등), 아이콘, 메타데이터를 담은 zip 아카이브입니다. Claude Desktop이 단일 클릭으로 브라우저 확장처럼 설치하고 서버를 로컬 서브프로세스로 실행합니다. MCP 서버는 stdio 위에서 JSON-RPC를 사용해 통신합니다.1115 Get Bananas는 자신의 서버를 이 방식으로 번들링해 출시합니다.
App Intents-에서-MCP로의 새 다리를 사용하지 않는 이유는?
그런 것이 없습니다. App Intents(Apple의 프레임워크)와 MCP(Anthropic의 프로토콜)는 독립적입니다. Apple Intelligence는 자체 해석기(resolver)를 통해 App Intents를 호출합니다. Claude Desktop은 자체 런타임을 통해 MCP 서버를 호출합니다. 두 표면을 다 원하는 앱은 둘 다 출시합니다. 자동 다리는 없습니다.1213
iCloud Drive 없이도 가능한가요?
가능합니다. 단, 단서가 있습니다. 공유 가능한 쓰기 가능 파일 위치라면 무엇이든 작동합니다: ~/Documents 안의 폴더, 네트워크 공유, S3로 마운트된 FUSE 파일 시스템. iCloud Drive가 편리한 이유는 Claude Desktop을 실행하는 모든 Mac과 사용자가 소유한 모든 iOS 디바이스에 이미 있기 때문입니다. iCloud가 아닌 파일은 사용자가 동기화를 별도로 설정하도록 강제할 것입니다.
쓰기 충돌이 발생하면 어떻게 되나요?
5초 파일 잠금에 50ms 재시도를 더한 구성은 MCP 쪽 동시 작성자(예: 첫 번째가 쓰는 도중 두 번째 MCP 호출이 도착하는 경우)를 처리합니다. Swift 앱과는 협조하지 않습니다. Swift 앱은 자체 코디네이터를 통해 씁니다. Swift와 Node가 진짜로 겹칠 때(Swift의 500ms 디바운스와 MCP 쓰기가 사용자 프롬프트에서만 발화한다는 점을 고려하면 드뭅니다), iCloud Drive는 파일 단위에서 last write wins로 해결합니다. 그다음 Swift 디코더의 isValid 필터가 이상한 것은 떨어뜨립니다.
CRDT나 운영 변환(operational transform)은 왜 안 쓰나요?
30개 항목짜리 쇼핑 리스트에는 과합니다. CRDT는 동시 편집이 흔하고 결정론적 병합 의미론이 필요할 때(협업 문서 편집기, 멀티 유저 게임) 옳은 선택입니다. 한 사람은 Claude를 통해 항목을 추가하고 다른 사람은 가게로 가는 길에 iOS 앱에서 체크하는 쇼핑 리스트라면, 디바운스 포함 last-write-wins가 정답입니다.
두 개의 에이전트 생태계, 하나의 쇼핑 리스트. 다리는 iCloud Drive와 너그러운 디코더가 있는 JSON 파일이며, 그것으로 충분합니다. 최소 공통 분모는 한계가 아닙니다. 그것은 두 생태계가 합의할 수 있는 유일한 것입니다.
참고 문헌
-
저자의 Get Bananas, 941 Apps가 출시한 iOS, macOS, watchOS, visionOS용 SwiftUI + SwiftData 쇼핑 리스트 앱. ↩
-
Get Bananas는 Claude Desktop을 위해
get-bananas.mcpb로 번들링된 MCP(Model Context Protocol) 서버를 함께 출시합니다. 노출되는 도구:get_shopping_list,add_item,remove_item,update_item,update_shopping_list. 서버는mcp-extension/server/index.js에 있는 575줄의 Node.js입니다. ↩↩ -
Apple Developer, “SwiftData” 프레임워크. iOS 17+, macOS 14+, watchOS 10+, visionOS 1+에서 사용 가능. 런타임 전용. 서버 측 또는 프로세스 간 바인딩 없음. ↩
-
Apple Developer, “CloudKit” 프레임워크. 네이티브
CKContainerAPI는com.apple.developer.icloud-services자격(entitlement)과 일치하는 Apple Developer 팀 식별자를 요구합니다. Apple은 비-Apple-플랫폼 클라이언트를 위해 CloudKit Web Services도 발행하지만, 이를 사용하려면 Get Bananas가 유지하지 않는 별도의 토큰/인증 다리가 필요합니다. ↩↩ -
Banana List/Item.swift의 프로덕션 코드.lastModified필드는 iCloud 동기화 충돌 해결을 위해 나중에 추가되었습니다. ↩ -
Banana List/iCloudBackupManager.swift의 프로덕션 코드. 상수는Banana List/Constants.swift에 있습니다. ↩↩ -
Banana List/ShoppingListExport.swift의 프로덕션 코드.decodeIfPresent기본값을 사용하는 커스텀 디코더와 임포트 시isValid필터. ↩↩ -
POSIX
O_EXCL | O_CREAT의미론. Node.js는fs.writeFileSync(path, data, { flag: 'wx' })로 같은 원자성을 노출합니다. Node.js fs 문서 참조. ↩ -
Apple Developer, “Designing for CloudKit”. 푸시 기반 동기화, 레코드 단위 충돌 해결, 존(zone) 파티셔닝. ↩
-
저자의 디버깅 기록. 무한 루프 사고는 싱크 카운터 로직이 도입되기 전, 30개 항목 쇼핑 리스트에서 3분 만에 4MB짜리
BananaList.json을 만들어 냈습니다. ↩ -
Anthropic, “Model Context Protocol”. LLM 도구 사용을 위한 개방형 프로토콜. 멀티 런타임(Claude Desktop, Cline, Goose 등). ↩↩↩↩
-
저자의 분석, App Intents Are Apple’s New API to Your App. 시스템-AI 표면(Apple) 전반과 크로스-LLM 도구 사용(Anthropic) 전반에 적용되는 두 개의 평행 계약 명제. ↩↩
-
Apple Developer, “App Intents 프레임워크”. Siri, Spotlight, Apple Intelligence를 위한 Apple의 타입화된-선언적 도구 사용 표면. ↩
-
Apple Developer, “FileManager url(forUbiquityContainerIdentifier:)”. 앱의 iCloud Drive 컨테이너 URL을 해석하기 위한 공식 지원 API.
~/Library/Mobile Documents/아래의 macOS 경로는 iCloud Drive가 컨테이너를 표면화하는 위치의 호스트-OS 구현 디테일이며, 앱이 호출해야 할 것은 심볼릭 API입니다. ↩ -
Anthropic, “Desktop Extensions”.
.mcpb형식은manifest.json, MCP 서버 진입점, 아이콘, 메타데이터를 담은 zip 아카이브입니다. Claude Desktop에서 단일 클릭 설치이며, 번들링된 서버를 stdio JSON-RPC 위에서 로컬 서브프로세스로 실행합니다. ↩ -
Apple Developer, “NSFileCoordinator”. 같은 협조 프로토콜에 옵트인하는 프로세스들 사이에서 파일 읽기와 쓰기를 조율합니다. iCloud Drive의
bird데몬,NSMetadataQuery기반 옵저버, 앱 자신이 모두 같은 경로를 건드릴 수 있을 때 필요합니다. ↩ -
POSIX
rename(2)은 소스와 대상이 같은 파일 시스템에 있을 때 원자적이어야 합니다.~/Library/Mobile Documents/아래의 iCloud Drive 로컬 미러는 단일 APFS 볼륨이므로, 형제 임시 파일과 정식 경로 사이의fs.renameSync는 어떤 리더의 관점에서도 원자적입니다. POSIX rename 명세 참조. ↩