Two Agent Ecosystems, One Shopping List: An MCP Server Living Alongside an iOS App

Get Bananas, my SwiftUI shopping list app, runs on iOS, macOS, watchOS, and visionOS.1 It also lives inside Claude Desktop as a .mcpb MCP extension exposing five tools: get_shopping_list, add_item, remove_item, update_item, update_shopping_list.2

Get Bananas shopping list on iPhone, the same JSON file the MCP server reads and writes When you ask Claude “add bananas, milk, and bread to my list,” Claude calls add_item three times and the next time I open the app on my phone, the items are there. No server. No account. No API key. The bridge is a single JSON file plus five layers of loop-prevention I had to add after shipping a v1.0 that wrote itself into a 4MB file in three minutes.

The interesting question is how. SwiftData is Apple-platform-runtime only and is not readable from a Node.js process.3 The native CloudKit framework needs the matching com.apple.developer.icloud-services entitlement and Apple Developer team identifier; Claude Desktop’s MCP subprocess has neither, so it cannot use CKContainer as my signed app does. CloudKit Web Services exists, but using it would require maintaining a separate token / auth bridge between the desktop process and Apple’s servers.4 So the obvious paths are closed.

The path I took is older and stranger. The Get Bananas app and its MCP server share state through a JSON file in iCloud Drive. The Swift app keeps a SwiftData model for in-app persistence and exports a BananaList.json file to its iCloud Drive container through NSFileCoordinator after every change. The Node.js MCP server reads and writes the same file with a 5-second exclusive file lock, stale-lock detection, and atomic temp-file-rename writes. iCloud Drive handles the cross-device sync. The two agent ecosystems (Apple Intelligence on the device, Claude Desktop on the Mac) operate on the same source of truth without ever calling each other.

This essay is about why that arrangement works, what it costs, and where it falls apart.

TL;DR

  • Get Bananas exposes the same shopping list to two LLM ecosystems: Apple Intelligence (via App Intents, planned) and Claude Desktop (via MCP, shipped).
  • The integration substrate is iCloud Drive plus a JSON file, not CloudKit, not a server, not a service.
  • Swift app: SwiftData @Model ShoppingItem for in-app speed; iCloud Drive JSON export for portability.
  • MCP server: 575 lines of Node.js, file lock with stale-lock detection, runs inside Claude Desktop’s .mcpb bundle.
  • Trade-off: file-based JSON syncs slower than CloudKit and has merge-conflict risk, but it works across any agent ecosystem that can read a file.

The Architecture On One Page

┌─────────────────────────────────────────────────────────┐
                    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                         
└─────────────────────────────────────────────────────────┘

Two surfaces. One file. The whole bridge is the file.

The Swift Side: SwiftData For Speed, JSON For Portability

In the app, the shopping list is a SwiftData @Model. Real production code: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?
}

That is the in-memory truth. Every keystroke, every checkbox tap, every section change writes to SwiftData. SwiftData drives the SwiftUI views. The app feels native because it is native. The backup trigger is hash-based: a .onChange(of: computeItemsHash()) watcher fires only when an item’s id, name, amount, section, checked, or optional state changes, never on a pure no-op edit.

The trick is that SwiftData is not the cross-process truth. It is the cross-process cache. Every change debounces 500ms and then writes a JSON file to the app’s iCloud Drive container through Apple’s coordinated-write API: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 is the supported way to write a file other processes (and iCloud Drive’s daemon) might read concurrently.16 Before that write happens, the manager reads the existing file and skips the write entirely if the JSON byte-for-byte matches. That cuts redundant iCloud Drive churn whenever a SwiftData change observer fires for a no-op edit. On restore, the manager retries up to three times with exponential backoff (1s, 2s, 4s, total budget 7s), because NSMetadataQuery reports a file change before iCloud Drive has actually downloaded the new bytes.6

The Codable shape of the file is intentionally permissive. ShoppingListExport decodes with defaults for every missing field and filters out items with empty names: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
        }
    }
}

The defensive decoder is on purpose. Whatever writes the JSON next (an MCP server, a future shortcut, a manual paste) will inevitably forget a field. The Swift side absorbs it. The shared file format is the contract; the Swift decoder is the forgiving party.

Get Bananas on macOS, the host where the MCP server runs as a Claude Desktop subprocess and reads the same iCloud Drive file

The Node Side: A 575-Line MCP Server That Reads The Same File

The MCP server lives in mcp-extension/server/index.js, distributed as get-bananas.mcpb for Claude Desktop’s extension system. It opens the same iCloud Drive file from the macOS host:2

const ICLOUD_FILE_PATH = path.join(
  os.homedir(),
  "Library/Mobile Documents/iCloud~com~941apps~Banana-List/Documents/BananaList.json"
);

Five tools: one pure read (get_shopping_list), three read-modify-write item-level tools (add_item, remove_item, update_item), and one bulk-replace tool (update_shopping_list) that writes without first reading. The MCP server also exposes the file as a separate read-only Resource for clients that prefer the resource API. Every write goes through a file lock with stale-lock recovery:

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.");
}

The lock pattern is older than Node.js. fs.writeFileSync with the 'wx' flag is the cross-platform version of O_EXCL | O_CREAT. If the lock file exists and is older than 5 seconds, the server assumes the previous holder crashed and reclaims it. If it exists and is fresh, the server waits 50ms and retries. After 5 seconds total, it gives up.8

The lock only synchronizes Node-side writers against each other (a second MCP invocation while the first is mid-write). It does not coordinate with the Swift app, which writes through NSFileCoordinator and String.write(atomically:) and never touches BananaList.json.lock. Genuine Swift/Node overlap is left to two weaker mechanisms: the Swift app debounces 500ms before writing, MCP writes happen only on user prompt, and any residual collision falls through to iCloud Drive’s last-write-wins resolution at file granularity.

Writes themselves use the atomic-temp-then-rename pattern, with a JSON parse check between:

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 on the same filesystem is POSIX-atomic: a reader on another process either sees the old file or the new file, never half-written bytes.17 Pinning the temp path to process.pid keeps two MCP server instances (rare, but possible if the user reinstalls Claude Desktop without restarting) from clobbering each other’s temp files. The mid-write JSON.parse is a paranoia step: if the serialization itself produced invalid JSON, the function aborts before the rename, leaving the canonical file untouched.

Why iCloud Drive, Not CloudKit

The choice that makes the architecture work is using iCloud Drive (file-based) instead of CloudKit (record-based) for the cross-process truth. CloudKit is what Apple recommends for app-to-app sync. It has higher-level conflict resolution, server-side push, and zone-based partitioning.9 The native CKContainer API is Apple-platform-only and entitlement-gated, so a Claude Desktop subprocess cannot use it as my signed app does. Apple does publish CloudKit Web Services for non-Apple-platform clients, but using it would require provisioning a server-to-server token, plumbing it into the MCP server, and maintaining a separate auth bridge: not impossible, but a substantial amount of infra for a shopping list.4

The MCP server runs unsandboxed on macOS as a subprocess of Claude. It has no Apple Developer signing chain, no team identifier match with my app’s CloudKit container, and no CloudKit Web Services token configured.

iCloud Drive, by contrast, exposes itself as a regular file system location. Apple’s supported API is FileManager.url(forUbiquityContainerIdentifier:) for the app side;14 on macOS, the resolved location for Get Bananas is ~/Library/Mobile Documents/iCloud~com~941apps~Banana-List/Documents/BananaList.json. That path is a macOS-specific implementation detail of where iCloud Drive surfaces the container, but for Claude Desktop running on the same Mac, it is just a file. Any process with read access to the user’s home directory can read and write it. So can a future shortcut, a future SwiftBar plugin, a future llama.cpp script the user runs locally. Anything that can read a file can integrate.

The cost is that iCloud Drive’s sync is slower than CloudKit (seconds, not sub-second) and has weaker conflict semantics (last-write-wins at file granularity, not record-level merge). For a shopping list with maybe 30 items, neither cost matters. For a high-write-volume app with 10K rows and concurrent editors, both costs would dominate.

Five Layers Of Loop Prevention

The trickiest piece of code in the Swift side is the loop prevention. Without it: the MCP server writes the JSON, iCloud Drive syncs it to iOS, the iOS app’s NSMetadataQuery notices the change, the app re-imports the JSON into SwiftData, the import triggers a SwiftData change observer, the change observer fires a debounced backup, the debounced backup writes the JSON, iCloud Drive syncs it back. I shipped the naive version in v1.0 and watched a 30-item shopping list balloon to 4MB in three minutes during testing.10

The shipped version uses five stacked guards, not one. Each guards a different timing edge case:

// 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
}
Layer Where What it catches
1. Sync counter > 0 Outbound write path Re-entrant writes triggered while a sync-from-iCloud is actively in flight
2. 5s post-sync window Outbound write path Delayed @Model onChange callbacks SwiftData fires after the import has settled
3. 2s post-backup window Inbound NSMetadataQuery handler Local file system events fired right after the app’s own write
4. Mod-date exact match Inbound handler iCloud Drive echoing our own backup back to us across devices
5. Monotonic mod-date Inbound handler NSMetadataQuery firing both DidUpdate and DidFinishGathering for a single change

The first two layers gate the outbound write path: should I export to iCloud right now? The remaining three gate the inbound NSMetadataQuery handler: should I import this change into SwiftData? Either side alone is insufficient. A single sync round-trip can pass through guards on both sides depending on which event fires first, so each path needs its own defenses.

The lesson generalizes to any “shared file across two writers” architecture: mod-time-based change detection is necessary but not sufficient. You need a stable identity for “writes I caused” that survives at least one round trip through the sync layer. The closest thing iCloud Drive gives you is the file’s modification date at the moment you wrote it. Hold on to it. Compare on the way back.

What I Would Build Differently

Four lessons from shipping this.

The defensive Codable on the Swift side earns its keep. The MCP server has been rewritten three times. Each rewrite forgot to set a field at least once. The Swift decoder absorbed every variant and the app never crashed. If I were starting over I would push more fields into “decode with default” rather than “required.” The contract between the two writers is fragile by design.7

The lock timeout should be content-aware, not mtime-only. Five seconds is short. If the user’s Mac is on slow Wi-Fi or the iOS device is restoring after a long background, an iCloud Drive sync of BananaList.json.lock can take longer than 5 seconds to propagate. The MCP server then sees a stale lock that is actually still held. The fix is to gate the stale-lock check on the PID written inside the lock file: if kill(pid, 0) reports a still-running process, do not break the lock no matter how old the mtime looks. The current code writes the PID but never reads it back.

The update_shopping_list tool was a mistake. It replaces the entire list. Claude Desktop occasionally calls it when a single-item op would do, then a non-trivial chunk of the user’s list disappears. I should have only shipped the four item-level tools (get, add, remove, update) and forced Claude to compose them. The MCP protocol’s destructiveHint: true annotation does flag the tool as destructive,11 but Claude does not always surface that to the user before calling. The bulk-replace tool is convenient for the LLM and dangerous for the user. The presence of a guardrail at the protocol layer doesn’t substitute for not shipping the foot-gun.

The shared JSON export needs a version field. ShoppingListExport decodes with permissive defaults, which works until the day I rename a field rather than add one. A schemaVersion: 1 at the top of the JSON would let either side detect a future breaking change and refuse the read instead of silently producing a malformed model. Migrations would still be manual, but at least the failure mode would be loud rather than silent data loss.

When Not To Use This Pattern

Refusal is part of the design.

If the data is regulated (health, finance, anything with a compliance retention policy), iCloud Drive’s user-controlled file system is the wrong substrate. CloudKit has logging and audit trails; user-readable JSON files do not.

If the cross-process latency budget is sub-second, iCloud Drive will not meet it. In my testing, iCloud Drive sync usually takes seconds rather than sub-second on a healthy connection; Apple does not publish a tighter SLA, and slow networks make it longer. CloudKit’s push-based delivery is materially faster for record-level updates. A real-time collaboration product needs CloudKit (or a dedicated sync server).

If the schema is evolving fast, the Codable-with-defaults pattern compounds debt. Every new field requires a “default for old files” decision that ages quickly. JSON file sync is best for stable schemas with mostly-additive change.

What This Means For Apps That Want To Be Reachable By Multiple Agent Ecosystems

The pattern is simple enough to repeat. Three pieces:

  1. A SwiftData @Model for in-app persistence. Drives the UI, fast, native.
  2. A Codable JSON export written to iCloud Drive on debounced change. Defensive decoder. Stable schema. The shared file is the contract.
  3. A small adapter for each agent ecosystem that reads and writes the same file with a file lock. Node.js for Claude Desktop. A future App Intent + AppEntity for Apple Intelligence. A future shell script for whatever ships next.

The pattern is portable because the integration substrate is the file system. Every agent runtime that exists today (Claude Desktop, Cursor, Goose, Cline) and most that will ship next year can read a file.11 CloudKit cannot. Native sync engines cannot. The lowest common denominator wins when the goal is reach across LLM ecosystems.

Anthropic and Apple disagree on what an agent should look like. App Intents say it is a typed Swift declaration that Apple Intelligence resolves on-device. MCP says it is a JSON-RPC server with a tool list that any LLM can call. Both are correct in their own ecosystems. Get Bananas treats neither as the source of truth and lets the file system mediate.12

The next time I ship an app that wants two agent surfaces, I will start with the file format before the entity model.

FAQ

What is .mcpb and how does it work?

A .mcpb is Anthropic’s MCP extension bundle format for Claude Desktop. It is a zip archive containing a manifest.json describing the tools, the MCP server entry point (Node.js, Python, etc.), an icon, and metadata. Claude Desktop installs it like a browser extension via single-click and runs the server as a local subprocess. The MCP server speaks JSON-RPC over stdio.1115 Get Bananas ships its server bundled this way.

Why not use the new App Intents-to-MCP bridge?

There isn’t one. App Intents (Apple’s framework) and MCP (Anthropic’s protocol) are independent. Apple Intelligence calls App Intents through its own resolver. Claude Desktop calls MCP servers through its own runtime. An app that wants both surfaces ships both; there is no automatic bridge.1213

Could you do this without iCloud Drive?

Yes, with caveats. Any shared writable file location works: a folder in ~/Documents, a network share, an S3-mounted FUSE filesystem. iCloud Drive is convenient because it’s already on every Mac that runs Claude Desktop and on every iOS device the user owns. A non-iCloud file would force the user to set up sync separately.

What happens when there’s a write conflict?

The 5-second file lock plus 50ms retries handles concurrent MCP-side writers (e.g., a second MCP invocation arriving while the first is mid-write). It does not coordinate with the Swift app, which writes through its own coordinator. When Swift and Node genuinely overlap (rare, given Swift’s 500ms debounce and that MCP writes only fire on user prompts), iCloud Drive resolves at file granularity: last write wins. The Swift decoder’s isValid filter then drops anything malformed.

Why not CRDTs or operational transform?

Overkill for 30-item shopping lists. CRDTs are the right choice when overlapping concurrent edits are common and you need deterministic merge semantics (collaborative document editors, multi-user games). For a shopping list where one person adds items via Claude and another checks them off via the iOS app on the way to the store, last-write-wins-with-debounce is correct.


Two agent ecosystems, one shopping list. The bridge is iCloud Drive plus a JSON file with a forgiving decoder, and that is enough. The lowest common denominator is not a limitation. It is the only thing both ecosystems agree on.

References


  1. Author’s Get Bananas, a SwiftUI + SwiftData shopping list app for iOS, macOS, watchOS, and visionOS, published by 941 Apps. 

  2. Get Bananas ships an MCP (Model Context Protocol) server bundled as get-bananas.mcpb for Claude Desktop. Tools exposed: get_shopping_list, add_item, remove_item, update_item, update_shopping_list. The server is 575 lines of Node.js in mcp-extension/server/index.js

  3. Apple Developer, “SwiftData” framework. Available iOS 17+, macOS 14+, watchOS 10+, visionOS 1+. Runtime-only; no server-side or cross-process bindings. 

  4. Apple Developer, “CloudKit” framework. The native CKContainer API requires the com.apple.developer.icloud-services entitlement and matching Apple Developer team identifier. Apple also publishes CloudKit Web Services for non-Apple-platform clients, but using it requires a separate token / auth bridge that Get Bananas does not maintain. 

  5. Production code in Banana List/Item.swift, December 1, 2025 commit (initial SwiftData model). The lastModified field was added later for iCloud sync conflict resolution. 

  6. Production code in Banana List/iCloudBackupManager.swift, November 24, 2025 initial commit. Constants live in Banana List/Constants.swift

  7. Production code in Banana List/ShoppingListExport.swift. Custom decoder with decodeIfPresent defaults plus isValid filter on import. 

  8. POSIX O_EXCL | O_CREAT semantics; Node.js exposes the same atomicity via fs.writeFileSync(path, data, { flag: 'wx' }). See Node.js fs documentation

  9. Apple Developer, “Designing for CloudKit”. Push-based sync, record-level conflict resolution, zone partitioning. 

  10. Author’s debugging notes from December 1, 2025. The infinite-loop incident produced a 4MB BananaList.json from a 30-item shopping list in 3 minutes before sync-counter logic landed. 

  11. Anthropic, “Model Context Protocol”. Open protocol for LLM tool use; multi-runtime (Claude Desktop, Cline, Goose, etc.). 

  12. Author’s analysis in App Intents Are Apple’s New API to Your App. The two parallel-contracts thesis applied across system-AI surfaces (Apple) and cross-LLM tool use (Anthropic). 

  13. Apple Developer, “App Intents framework”. Apple’s typed-declarative tool-use surface for Siri, Spotlight, and Apple Intelligence. 

  14. Apple Developer, “FileManager url(forUbiquityContainerIdentifier:)”. The supported API for resolving an app’s iCloud Drive container URL. The macOS path under ~/Library/Mobile Documents/ is the host-OS implementation detail of where iCloud Drive surfaces the container; the symbolic API is what apps should call. 

  15. Anthropic, “Desktop Extensions”. The .mcpb format is a zip archive containing manifest.json, MCP server entry point, icon, and metadata. Single-click install in Claude Desktop; runs the bundled server as a local subprocess over stdio JSON-RPC. 

  16. Apple Developer, “NSFileCoordinator”. Coordinates reads and writes to a file across processes that opt into the same coordination protocol; required when iCloud Drive’s bird daemon, NSMetadataQuery-driven observers, and the app itself can all touch the same path. 

  17. POSIX rename(2) is required to be atomic when the source and destination are on the same filesystem. iCloud Drive’s local mirror under ~/Library/Mobile Documents/ is a single APFS volume, so fs.renameSync between a sibling temp file and the canonical path is atomic from any reader’s perspective. See POSIX rename specification

Verwandte Beiträge

App Intents Are Apple's New API to Your App

I shipped an App Intent in Water on Feb 8, 2026. Here's what Apple Intelligence wants from third-party apps, and why App…

16 Min. Lesezeit

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

17 Min. Lesezeit

Your Agent Has a Middleman You Didn't Vet

Researchers tested 28 LLM API routers. 17 touched AWS canary credentials. One drained ETH from a private key. The router…

15 Min. Lesezeit