AI

ターミナルにリマインダー権限を渡さずにMCPからEventKitを触る

  • macOS
  • Swift
  • MCP
  • TCC
  • EventKit
  • Vigilare

Vigilare はMCPサーバーを内蔵していて、Claude Codeなどからリマインダーを操作できます。ところがClaude Desktop内のClaude Codeから呼ぶときだけ、Reminders access not authorized で失敗していました。原因はmacOSのプライバシー権限(TCC)の帰属が親プロセスから切り離されることで、EventKitアクセスを独立したLaunchAgentのdaemonへ集約して解決しています。

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.

副産物として、権限の持たせ方も整理されました。ターミナルやClaude Desktopにリマインダー権限を与える必要がなくなり、アプリ本体のBundle IDだけに絞れています。

同じMCPサーバーが、呼び出し元によって失敗する

通常のClaude Codeから vigilare_get_reminders を呼ぶと問題なく動きます。ところが同じツールを、Claude Desktop内のClaude Codeから呼ぶと権限エラーになりました。MCPサーバーのバイナリも、渡している引数も同じです。

違うのは起動経路だけでした。Claude Desktopは、MCPサーバーの子プロセスを Claude.app/Contents/Helpers/disclaimer という内部ヘルパー経由で起動します。このヘルパーは responsibility_spawnattrs_setdisclaim() という非公開APIを呼び、子プロセスのTCC責任プロセスを親から意図的に切り離します。

通常のClaude Codeからは--mcpプロセスが直接起動し、責任プロセスが親のまま引き継がれるためターミナルに付いた許可でリマインダーが返る。Claude Desktopからは間にdisclaimerヘルパーが1段挟まり、責任プロセスが切り離された--mcpプロセスはどのBundle IDの許可も引けずReminders access not authorizedで終わる

TCCはBundle ID単位で記録される

ここで前提を1つ整理しておきます。macOSのプライバシー権限は、プロセス単位ではなくコード署名のBundle ID単位で記録されます。

つまり「ターミナルからPythonスクリプトを動かしてリマインダーを読む」ような使い方をすると、権限を持つのはターミナルアプリになります。一度許可すると、そのターミナルから起動するあらゆるプログラムが同じ権限で動けることになります。

Vigilareの --mcp プロセスは jp.labee.floating-reminders として署名されているので、本来はアプリ本体の許可をそのまま引き継げるはずでした。それがdisclaimによって切り離され、責任の所在が曖昧な状態でEventKitを呼ぶことになっていたわけです。

権限を持つプロセスを1つに絞る

解決の方向は、EventKitを触るプロセスを1つに固定し、そこへ全て集約することでした。

独立したLaunchAgentとして --daemon モードのプロセスを常駐させ、EventKitアクセスをそこだけに閉じ込めます。MCPクライアントから起動される --mcp プロセスは、EventKitに一切触れません。Unixドメインソケット経由でdaemonへ中継するだけです。

各MCPクライアントから起動された--mcpプロセスはEventKitに触れず、Unixドメインソケット経由でdaemonへ中継するだけになる。EventKitを呼ぶのはLaunchAgentとして常駐するdaemonプロセスだけで、アプリ本体と同じバイナリ・同じBundle IDで起動する

daemonはアプリ本体と同じバイナリ・同じBundle IDで起動します。plistの BundleProgram にアプリバンドル内のパスを指定し、引数に --daemon を渡す形です。

<key>BundleProgram</key>
<string>Contents/MacOS/Vigilare</string>
<key>ProgramArguments</key>
<array>
  <string>Vigilare</string>
  <string>--daemon</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>

別バイナリや別Bundle IDにすると、TCCの許可を取り直すプロンプトが出ます。同一バイナリの使い回しは、この構成では必須条件でした。

launchdが正規の起動経路としてアプリバンドル内のバイナリを起動するので、disclaimのような責任プロセスの切り離しが介在しません。TCCはこれを同一アプリとして認識します。

CLIとして配布すると、権限はターミナルに付く

この構成の副次的な効果を書く前に、比較対象を確認しておきます。

macOS向けのMCPサーバーを配布する方法として、単体のCLIバイナリとして配る形がよく採られます。npx で動くものや、Homebrewで入れるものです。手軽ですし、アプリを別途インストールしてもらう必要もありません。

ただしEventKitのようなプライバシー保護されたAPIを触る場合、この方式には避けられない副作用があります。TCCの許可がCLIバイナリ自身ではなく、それを起動したアプリに付くという点です。ターミナルから起動したなら、許可を持つことになるのはターミナルアプリです。

macOSの「システム設定 > プライバシーとセキュリティ > リマインダー」を開くと、ターミナルが並んでいることがあります。ユーザーが意図してターミナルにリマインダーを触らせたかったわけではなく、そこで動かした何かのツールが要求した結果です。

そして一度許可すると、そのターミナルから起動するあらゆるプログラムが同じ権限を持ちます。 別のCLIツールも、パッケージマネージャが入れたバイナリも、書き捨てのシェルスクリプトも、自分では許可を求めていないのにリマインダーを読み書きできる状態になります。

ターミナルアプリにリマインダーを許可すると、そのターミナルから起動するMCPサーバー・別のCLIツール・入れた覚えのないバイナリ・シェルスクリプトが、すべて同じ権限で動けるようになる

なおClaude Desktopから起動した場合は、Claude Desktopに権限が付くわけでもありません。冒頭のdisclaimによって責任プロセスが切り離されるので、どのBundle IDにも紐づかず、許可を求めるプロンプトすら出ないまま Reminders access not authorized で終わります。この経路ではCLI方式そのものが成立しません。

権限を持つのはアプリ本体だけになった

EventKitを触るのはdaemonだけになったので、リマインダー権限を必要とするのは jp.labee.floating-reminders の1つだけです。ターミナルにも、Claude Desktopにも、リマインダーの権限を与える必要がありません。

CLIとして配布するとターミナルアプリに権限が付き、そのターミナルから起動する全てのプログラムが同じ権限を持つ。daemonに集約するとアプリ本体のBundle IDだけが権限を持つ

もともとはdisclaimによる権限エラーを回避するための構成でしたが、こちらの効果のほうが大きいかもしれません。ユーザーから見て「Vigilareにリマインダーを許可した」という状態は分かりやすく、許可した範囲と実際に動くものが一致します。ターミナルに許可を与えて、そこから何が動くのかは把握できない、という状態にはなりません。

デフォルトでは登録しない

daemonは常駐プロセスなので、必要のないユーザーにまで自動登録するのは避けたいところです。設定画面のトグルで明示的にONにしたときだけ SMAppService.agent(plistName:) で登録し、OFFにするとlaunchdへの登録ごと抹消します。プロセスを止めるだけでなく、痕跡を残さない形にしています。

daemonが未登録の状態でMCPツールを呼んだ場合は、EventKitへフォールバックせず、設定を有効にするよう促すエラーを返します。フォールバックさせてしまうと、「Background Serviceが無効なのにMCPが動く」という状態が生まれて挙動が読めなくなるためです。

dispatchMain()ではハングする

実装して数分動かすと、daemonが応答しなくなりました。launchctl 上は state=running のまま、全ての接続が無反応になります。

sample でスタックダンプを取ると原因が見えました。EventKitは変更通知の内部処理をメインのRunLoopに依存しています。リマインダーに変更が発生すると、通知の処理が NSOperation.waitUntilFinished でメインキュー上をブロックします。

--daemon の分岐でメインスレッドを dispatchMain() で待機させていたのが問題でした。RunLoopが回っていないのでオペレーションが完了せず、メインキュー全体が閉塞します。ソケット接続のコールバックもメインキューで配送されるため、新規セッションが全滅する形です。

リマインダーの変更通知がNSOperation.waitUntilFinishedでメインキューを塞ぎ、メインキューはそのoperationが終わるまで次を捌かないため互いに待ち合う。dispatchMain()では待機するだけでRunLoopが回らないのでこの輪が解けず、同じキューに積まれるソケット接続のコールバックも止まる

// dispatchMain() ではなく RunLoop.main.run() を使う
RunLoop.main.run()

GUIアプリの中でリスナーを常駐させる案を最初に試したときは、この問題は起きませんでした。NSApplication がRunLoopを回してくれるためです。daemonとして独立プロセスにして初めて踏んだ地雷でした。

接続はツール呼び出しごとに開き直す

--mcp プロセスとdaemonの間は、接続を張りっぱなしにせず、ツール呼び出しのたびに新しい接続を開いて閉じています。

起動時に一度だけ疎通を確認する方式も考えましたが、採りませんでした。daemonが会話の途中で落ちた場合や、後から有効化された場合に、セッション全体が使えないままになります。呼び出しごとに判定すれば、daemonが復帰した次の呼び出しから普通に動きます。

接続を保持しない副次的な効果として、呼び出しの合間はdaemon側のアクティブセッション数がゼロに戻ります。バイナリが更新されたときにdaemonが自分を再起動する処理は、この「セッションがない瞬間」を待って動くので、この性質に依存しています。

同じ問題を他のアプリでも解いていた

この daemon と forwarder の分離は、Vigilare だけの問題ではありませんでした。同じ構成を必要とするアプリが社内に他にもあり、それぞれで解き直していたので、共通部分をSwiftパッケージとして切り出しています。

daemonのライフサイクル、Unixドメインソケットの待受、呼び出しごとの中継、ソケットパスの検証といった配管はそちらへ移し、Vigilare側にはツールの定義とハンドラだけが残りました。

サンドボックスとソケットパスの制約

実装にあたって効いてきた制約を2つ書いておきます。

macOS 14.2以降、SMAppService で登録するagentやdaemonは、本体アプリがサンドボックス化されているなら対象の実行ファイルもサンドボックス化されている必要があります。 同一バイナリを使い回す構成なので、この要件は自動的に満たされました。別のヘルパーバイナリを用意していたら、ここで追加の作業が発生していたはずです。

もう1つはUnixドメインソケットのパス長です。sun_path は104バイトまでという制限があり、超えると invalid argument になります。サンドボックスアプリのコンテナ配下にソケットを置くとパスが長くなるので、生成時に長さを検証する処理を入れています。ユーザー名が長い環境では実際に引っかかりうる制約です。