追加インストール不要で動くMCP対応macOSアプリの作り方
こんにちは、K@zuki.です。
皆さんは自分のアプリをClaude DesktopやClaude Codeから操作できたら便利だと思ったことはありませんか?
MCP(Model Context Protocol)を使えばそれが実現できるわけですが、実際に導入しようとすると意外と面倒なんですよね。
今回は、リマインダー管理アプリVigilareで採用した、アプリ自体がMCPサーバーとして動作する設計パターンを紹介します。
要約
- 一般的な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を操作 |
これらの方式だと、ユーザーは以下の手順を踏む必要があります。
- アプリをインストール
- MCPサーバーを別途インストール(npm install等)
- Claude Desktopの設定ファイルを編集
正直、面倒ですよね。
また、当初はHTTP/SSEトランスポートでアプリ内にMCPサーバーを起動する方式を実装してましたが、接続が不安定で実用に耐えませんでした。
アプリ自体を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を使っているアプリなら共通のテクニックとして利用できます。
つまり、以下のような流れになります。
- MCPサーバー経由でリマインダーを変更
- EventKitが変更を検知
- 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つのアプリでこのパターンを採用しています。
Discussion