iOS・macOS

EventKitのcalendar(withIdentifier:)はcalendars(for:)と食い違うことがある

  • macOS
  • Swift
  • EventKit
  • Vigilare
  • MCP

VigilareのMCPツールでlist_idを指定してもリマインダーが絞り込まれないことがある不具合を修正しました。原因はEventKitの2つのカレンダー取得APIが食い違う場合があり、片方だけがnilを返す状況でエラーなく「全カレンダー」へフォールバックしていたことです。

VigilareFloating Reminders for macOSKeep your Apple Reminders always on top with a floating window for macOS. Works in fullscreen apps, with quick actions, list filtering, and a built-in Markdown editor.

Vigilareは自前のデータベースを持たない

Vigilare はタスク管理アプリですが、タスクを保存する自前のデータベースを持っていません。Appleの標準リマインダーアプリと同じ保存先、つまりEventKitが管理するストアをそのまま読み書きしています。

この設計だと、標準アプリで追加したタスクがVigilareにも即座に現れますし、iPhoneで完了にしたタスクがMacのVigilareにも反映されます。同期の仕組みを自前で書く必要がありません。その代わり、データの見え方はEventKitとiCloudの都合に従うことになります。

EventKitでは、リマインダーの「リスト」は EKCalendar として表現されます。カレンダーとリマインダーのリストが同じ型で扱われていて、allowedEntityTypes にどちらを入れられるかが入っています。今回の不具合は、このカレンダーを取得するAPIが2種類あり、両者が食い違う瞬間があることに起因します。

Vigilareはさらに、MCPサーバーを内蔵していてClaude Codeのようなツールからリマインダーを操作できます。標準アプリ、Vigilareの画面、MCP経由のクライアントが、いずれも同じストアを見ていることになります。

標準リマインダー、Vigilareの画面、MCP経由のClaude Codeが同じEventKitのストアを読み書きし、そのストアがiCloudと双方向に同期する構成

今回はそのMCPツールに絞り込み条件を渡しても効かない、という報告から調査を始めました。

list_idを指定しても全件返ってくる

vigilare_get_reminders にlist_idを渡すと特定のリストだけに絞り込めるはずが、他のリストのリマインダーまで混ざって返ってくることがある、という報告でした。エラーは出ません。呼び出し自体は成功し、結果の件数だけが多すぎる状態です。

呼び出し側のバリデーションはリスト一覧から該当IDが存在するかを確認していて、そこは通過しています。フィルターが効かなくなるのは、バリデーションの後、実際にリマインダーを取得する層でした。

list_idを渡すとハンドラのバリデーションがcalendars(for:)で確認して通過し、その後のリマインダー取得層がcalendar(withIdentifier:)で引き直して解決できず、エラーなく全件が返る流れ

calendars(for:)とcalendar(withIdentifier:)は別のAPI

EventKitには、指定したエンティティ種別のカレンダーを列挙する calendars(for:) と、識別子から直接1件を引く calendar(withIdentifier:) という、目的の異なる2つの取得APIがあります。前者は「リマインダー用のリストを全部ください」、後者は「このIDのものを1件ください」という問い合わせで、答えを取ってくる経路が違います。

Vigilareのコードでは、MCPハンドラ側のバリデーションが calendars(for: .reminder) の列挙結果に対してlist_idの一致を確認する一方、実際にリマインダーを取得する層は calendar(withIdentifier:) で同じIDを引き直していました。同じ識別子を渡しているつもりでも、参照している情報源が違います。

普段はどちらも同じ結果を返すため問題になりません。ところが、calendars(for:) の列挙には出てくるのに calendar(withIdentifier:) だけがnilを返す瞬間があります。手元で観測した限りではiCloud同期の前後で起きていて、コード側にも同期の遅延を疑うコメントを残していますが、EventKitがどういう条件で両者をズラすのかはAppleのドキュメントからは確認できていません。原因が何であれ、食い違いうるという事実だけで修正の理由としては足ります。

修正前と修正後の分岐の比較。修正前はcalendar(withIdentifier:)がnilを返すと全カレンダーへ落ち、フィルターが黙って外れる。修正後はcalendars(for:)を先に見て、解決できなければ空配列を返す

修正前のコードは、次のような組み立てになっていました。

private func resolveCalendars(for listId: String?) -> [EKCalendar] {
    if let listId = listId,
        let calendar = eventStore.calendar(withIdentifier: listId)
    {
        return [calendar]
    }
    return eventStore.calendars(for: .reminder)
}

calendar(withIdentifier:) がnilを返した場合、listId が指定されていないときと同じ経路に落ちて、全リストのカレンダーを返してしまいます。呼び出し元はlist_idが存在することを確認済みなので、エラーにはなりません。結果として、フィルターを指定したのに全件返ってくる、という現象だけが起きます。

空を返す方に倒す

修正では、まず calendars(for: .reminder) の列挙結果から一致するカレンダーを探し、そこで見つからない場合だけ calendar(withIdentifier:) へフォールバックする順序に変えました。バリデーションと同じ情報源を優先することで、両者の食い違いをできるだけ起こしにくくします。

func resolveCalendars(for listId: String?) -> [EKCalendar] {
    guard let listId = listId else {
        return eventStore.calendars(for: .reminder)
    }

    let reminderCalendars = eventStore.calendars(for: .reminder)
    if let calendar = reminderCalendars.first(where: { $0.calendarIdentifier == listId }) {
        return [calendar]
    }

    if let calendar = eventStore.calendar(withIdentifier: listId),
        calendar.allowedEntityTypes.contains(.reminder)
    {
        return [calendar]
    }

    return []
}

もう一つ変えたのは、どちらのAPIでも解決できなかったときの返し値です。修正前は「全カレンダー」に倒れていましたが、修正後は空配列を返します。list_idが指定されているのに解決できないなら、フィルター対象なしとして空を返す方が、絞り込みが暗黙に外れて全件返るより安全だという判断です。呼び出し元からすると、指定した条件に一致するものがなかったことが結果の件数からそのまま分かります。

calendar(withIdentifier:) で解決できた場合も、allowedEntityTypes がリマインダーを含むことを確認してから採用しています。同じ識別子空間にイベント用のカレンダーが存在するケースで、リマインダー取得に紛れ込ませないための防御です。

2つのAPIを独立に制御してテストする

この手のバグは、2つのAPIが「普段は一致する」ことに支えられているぶん、テストで再現するには両者を意図的にズラす必要があります。EKEventStore のサブクラスを作り、calendars(for:)calendar(withIdentifier:) をそれぞれ独立したプロパティで差し替えられるようにしました。

private final class ListIdResolutionEventStore: EKEventStore {
    var reminderCalendarsToReturn: [EKCalendar] = []
    var calendarByIdentifierToReturn: [String: EKCalendar] = [:]

    override func calendars(for entityType: EKEntityType) -> [EKCalendar] {
        reminderCalendarsToReturn
    }

    override func calendar(withIdentifier identifier: String) -> EKCalendar? {
        calendarByIdentifierToReturn[identifier]
    }
}

このスパイを使えば、「calendars(for:) の列挙には出てこないが calendar(withIdentifier:) では引ける」という同期タイミング依存の状況や、「どちらでも解決できない」という状況を、実機のiCloud同期を待たずに再現できます。修正の柱になった「解決できなければ空を返す」という挙動も、このテストで担保しています。

他の呼び出し箇所も同じ分岐を持っていないか

今回直したのはリマインダー取得の経路ですが、MCPサーバーの他のハンドラにも、バリデーションと実際のフェッチで異なるEventKit APIを参照している箇所がないかは、まだ横断的に確認できていません。同じ「2つのAPIが基本的には一致するので気づきにくい」という構造が他にも潜んでいる可能性があり、次に手を入れるときの点検項目として残しています。