🦉

追加インストール不要で動くMCP対応macOSアプリの作り方

に公開

こんにちは、K@zuki.です。

皆さんは自分のアプリをClaude DesktopやClaude Codeから操作できたら便利だと思ったことはありませんか?
MCP(Model Context Protocol)を使えばそれが実現できるわけですが、実際に導入しようとすると意外と面倒なんですよね。

今回は、リマインダー管理アプリVigilareで採用した、アプリ自体がMCPサーバーとして動作する設計パターンを紹介します。

https://apps.apple.com/lt/app/vigilare-enhanced-reminders/id6748925073?mt=12

要約

  • 一般的なMCPサーバーはスタンドアロン型だが、アプリ自体がMCPサーバーになる設計を採用した
  • 自前のHTTP/SSEトランスポートは不安定だったため、stdioトランスポートに切り替えた
  • EventKitがデータ同期層として機能するため、GUI⇔MCP間のIPCが不要

既存のMCPサーバー実装の課題

課題というわけでもないんですが、既存の方式について振り返ってみましょう。
MCPサーバーの実装方法を調べてみると、ほとんどがスタンドアロン型なんですよね。

方式 特徴
スタンドアロン filesystem, git, memory Python/Node.js製、別途インストール
AppleScript経由 python-apple-mcp 外部からAppleScriptで操作
IDE連携 xcode-mcp-server 独立したツールがIDEを操作

これらの方式だと、ユーザーは以下の手順を踏む必要があります。

  1. アプリをインストール
  2. MCPサーバーを別途インストール(npm install等)
  3. Claude Desktopの設定ファイルを編集

正直、面倒ですよね。

また、当初はHTTP/SSEトランスポートでアプリ内にMCPサーバーを起動する方式を実装してましたが、接続が不安定で実用に耐えませんでした。

https://khasegawa.hatenablog.com/entry/2025/06/10/205422

アプリ自体をMCPサーバーにする

そこで採用したのが、アプリ自体がMCPサーバーとして動作する設計です。

同一バイナリがコマンドライン引数によって異なるモードで動作します。
--mcp フラグがあればMCPサーバーとして起動し、なければ通常のGUIアプリとして起動するようにmain.swiftを実装します。
シンプルですよね。

import Foundation

let arguments = CommandLine.arguments

if arguments.contains("--mcp") {
  let semaphore = DispatchSemaphore(value: 0)
  Task {
    do {
      try await MCPServer.run()
    } catch {
      fputs("MCP Server error: \(error)\n", stderr)
      exit(1)
    }
    semaphore.signal()
  }
  semaphore.wait()
  exit(0)
}

VigilareApp.main()

MCPサーバーの実装

MCPサーバーの実装には、公式のmodelcontextprotocol/swift-sdkを使用しています。

StdioTransportを使用することで、標準入出力経由でClaude Desktopと通信します。
自前のHTTP/SSEと違って接続が安定しています。

import MCP

enum MCPServer {
  static func run(reminderStore: ReminderStoreProtocol = ReminderStore.shared) async throws {
    let server = Server(
      name: "Vigilare",
      version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
      capabilities: .init(
        tools: .init(listChanged: false)
      )
    )

    let handlers = MCPToolHandlers(reminderStore: reminderStore)
    let tools = buildTools()

    await server.withMethodHandler(ListTools.self) { _ in
      .init(tools: tools)
    }

    await server.withMethodHandler(CallTool.self) { params in
      try await handleToolCall(params, handlers: handlers)
    }

    let transport = StdioTransport()
    try await server.start(transport: transport)
    await server.waitUntilCompleted()
  }
}

Tool定義の注意点

Tool定義では、inputSchemaの書き方に注意が必要です。
swift-sdkのREADMEにある簡略化された例は動作しない場合があります(Issue #128)。

// ❌ READMEの例(動作しない場合がある)
Tool(
  name: "example",
  description: "Example tool",
  inputSchema: .object([
    "id": .string("The ID")
  ])
)

// ✅ 正しい書き方
Tool(
  name: "example",
  description: "Example tool",
  inputSchema: .object([
    "type": .string("object"),
    "properties": .object([
      "id": .object([
        "type": .string("string"),
        "description": .string("The ID"),
      ])
    ]),
    "required": .array([.string("id")]),
  ])
)

冗長ですが、こちらの形式なら確実に動作します。

EventKitによるUI同期

この設計の面白いところは、GUI⇔MCP間でIPCが不要な点です。

Vigilareはリマインダーの管理にEventKitを使用しています。
EventKitはmacOSのリマインダーアプリとデータを共有しており、変更があると通知が飛びます。
これは、このアプリ独自というわけではなく、EventKitを使っているアプリなら共通のテクニックとして利用できます。

つまり、以下のような流れになります。

  1. MCPサーバー経由でリマインダーを変更
  2. EventKitが変更を検知
  3. GUIアプリが変更通知を受け取り、UIを自動更新

GUIアプリとMCPサーバーが直接通信する必要がないわけです。EventKitがデータ同期層として機能してくれます。

セットアップ方法

Claude Codeの場合は、以下のようにevalを使ってインストールすることができます。

eval $(/Applications/Vigilare.app/Contents/MacOS/Vigilare --mcp-install)

--mcp-configを使うと、他のAIツールでも利用できる設定情報が出力されます。

まとめ

今回紹介した設計パターンの利点をまとめます。

  • アプリを入れたらMCPも使える(追加インストール不要)
  • stdioトランスポートでHTTP/SSEの不安定さを回避
  • EventKitがデータ同期層として機能し、IPCの実装が不要
  • ヘルパーコマンドでセットアップを簡略化

このパターンは、EventKitに限らず、CoreDataやCloudKitなど、OSが提供するデータ層を使っているアプリであれば応用できると思います。

macOSネイティブアプリを開発していて、MCPに対応したいと考えている方は、ぜひ参考にしてみてください。

現在、以下の2つのアプリでこのパターンを採用しています。

https://apps.apple.com/lt/app/vigilare-enhanced-reminders/id6748925073?mt=12

https://apps.apple.com/lt/app/chimr-meeting-reminder/id6746831187?mt=12

Discussion