PythonブリッジでmacOSアプリのAI統合をシンプルにした話
無印でカフェラテベースが気に入り始めているK@zuki.です。
以前、HTTP/SSEを使用してmacOSメニューバーアプリケーションにMCP Serverを実装する方法を紹介しました。
今回は、FastMCPを使用して、より堅牢で保守性の高いものに進化した経緯を共有したいと思います。
TL;DR
- 直接的なHTTP/SSE実装をFastMCPベースのPythonブリッジに置き換え
- ホストバリデーションとアクセス制御によりセキュリティを改善
- 標準的なstdio通信を使用してClaude Desktopとの統合を改善
- エコシステムとの互換性を獲得しつつ、同じユーザー体験を維持
なぜ動作しているものを変更するのか?
Chimrに最初にMCPサポートを実装した際、純粋なSwiftのHTTP/SSEなMCP Serverを実装していました。
動作はしていたものの、使用するにつれていくつかの問題が明らかになりました。
- 非標準的な実装 ... 私のカスタムHTTPアプローチは、ほとんどのMCP Serverの動作方法と一致していませんでした
- 保守の負担 ... MCPプロトコルの変更に追従するには、Swiftコードの更新が必要でした
- 限定的なエコシステム統合 ... 実装当時の時点でHTTP/SSEは非推奨だったこともあり統合がやや困難
簡単に言えば、MCPエコシステムの流れに逆らっていたわけです。
そのため、他のMCP Clientとの統合も踏まえて実装を始めました。
新しいアーキテクチャ
現在のシステムの動作方法は以下の通りです。
Claude Desktop <--(stdio)--> chimr.py <--(HTTP)--> Swiftアプリ
Claude DesktopがSwiftのHTTP Serverと直接通信する代わりに、ブリッジとして機能するPythonベースのFastMCP Serverと通信するようになりました。
これは複雑さを追加しているように見えるかもしれませんが、実際には大幅に単純化されています。
そのため、以前のHTTP/SSEでは対応できていなかったMCP Clientでも利用可能になっています。
FastMCP Server
新しいシステムの中核はchimr.py
です。基本的な構造は以下の通りです。
chimr.py
#!/usr/bin/env uv run --script
# /// script
# dependencies = [
# "mcp",
# "aiohttp"
# ]
# ///
"""
Chimr - Chimrカレンダー統合のためのMCP Server
"""
import asyncio
from contextlib import asynccontextmanager
from typing import Any, Dict, AsyncIterator
import aiohttp
from mcp.server.fastmcp import FastMCP
# Chimrへの接続設定
CHIMR_HOST = os.getenv("CHIMR_HOST", "127.0.0.1")
CHIMR_PORT = int(os.getenv("CHIMR_PORT", "8080"))
class ChimrConnection:
def __init__(self, host: str = CHIMR_HOST, port: int = CHIMR_PORT):
self.host = host
self.port = port
self.base_url = f"http://{host}:{port}"
self.session: Optional[aiohttp.ClientSession] = None
async def send_request(self, method: str, params: dict = None) -> dict:
"""ChimrにJSON-RPCリクエストを送信"""
request_data = {
"jsonrpc": "2.0",
"method": method,
"id": 1
}
if params:
request_data["params"] = params
# ... SwiftアプリにHTTPリクエストを送信
FastMCPの良い点は、MCPの複雑さをすべて処理してくれることです。
ツールの定義に集中するだけで済むので実装が楽にできます。
また、気づいた人がいるかもしれませんが、uv run --script
で実行することで動的に依存関係を取得するようにしています。
これ自体はサプライチェーン攻撃などに非常に脆弱なんですが、とりあえずの動作確認としては簡単なのでおすすめです。
chimr.py
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"""ライフサイクル管理"""
try:
await chimr_connection.connect()
yield {}
finally:
await chimr_connection.disconnect()
# 適切なライフサイクル管理を備えたMCP Serverを作成
mcp = FastMCP(
"Chimr",
description="Model Context ProtocolによるChimrカレンダー統合",
lifespan=server_lifespan
)
@mcp.tool()
async def get_today_events() -> str:
"""Chimrから今日のカレンダーイベントを取得"""
response = await chimr_connection.send_request(
"tools/call",
{"name": "get_today_events", "arguments": {}}
)
# ... レスポンスを処理
FastMCPがすべてのstdio通信、JSON-RPCパース、エラーハンドリングを処理してくれるので、ツールを実装するだけで済みます。
ツールごとに定義しなければならないことは面倒ではあるものの、シンプルな実装で済みますね。
Swift
Swift側では、HTTP Serverは残っていますが、目的が異なります。
以前のようなHTTP/SSEなMCP Serverではなく、Pythonブリッジが呼び出すための内部APIになりました。
Swift - HTTP Server
private func handleHTTPRequest(_ request: HTTPRequest, connection: NWConnection) {
if request.method == "POST" && request.path == "/" {
if let body = request.body, let mcpRequest = MCPRequest(from: body) {
let response = protocolHandler?.handleRequest(mcpRequest)
?? MCPResponse(error: MCPError(code: -32603, message: "Internal error"), id: mcpRequest.id)
sendHTTPResponse(response: response, connection: connection)
}
}
}
このようにPythonからのリクエストを処理して、レスポンスを返すだけのシンプルな実装にできます。
こうすることでテストも書きやすくメンテしやすくなるわけです。
セキュリティの強化
HTTPサーバーを実行する際の懸念事項の1つは(localhostであっても)セキュリティです。
新しい実装では、いくつかの保護層を追加しています。
Swift - 接続元の制限
private func isConnectionAllowed(_ connection: NWConnection) -> Bool {
let settings = AppSettings.shared
// 外部アクセスが許可されている場合、すべての接続を受け入れる
if settings.mcpAllowExternalAccess {
return true
}
// リモートエンドポイントが許可されたホストにあるかチェック
guard case .hostPort(let host, _) = connection.endpoint else {
return false
}
switch host {
case .ipv4(let ipv4):
let address = ipv4.debugDescription
return settings.mcpAllowedHosts.contains(address)
|| settings.mcpAllowedHosts.contains("localhost")
case .ipv6(let ipv6):
let address = ipv6.debugDescription
return settings.mcpAllowedHosts.contains(address)
|| settings.mcpAllowedHosts.contains("localhost")
case .name(let name, _):
return settings.mcpAllowedHosts.contains(name)
@unknown default:
return false
}
}
これにより、ユーザーは以下のことができます。
- localhostのみに接続を制限する(デフォルト)
- 特定の許可されたホストを定義する
- 必要に応じて外部アクセスを有効にする
現在はUnix Domain Socketでの実装を検討しており、こちらを標準とすればネットワーク経由でのアクセスを防げるのでより安全になります。
このアプローチの利点
このアーキテクチャをしばらく実行した後、利点は明確です。
- 標準準拠 ... Claude Desktopなど一般的なMCP Clientは標準的なstdioベースのMCPサーバーを認識可能
- 変更容易性 ... プロトコルの変更はPythonブリッジの更新で対応できる
- テスト容易性 ... Swift APIをMCPとは独立してテストできる
- エコシステムへのアクセス ... PythonのMCPツールやライブラリを活用可能
実装のヒント
macOSアプリに同様のアーキテクチャでMCP Serverの実装を検討している場合、学んだ教訓をいくつか紹介します。
接続ライフサイクルを適切に処理する
FastMCPはライフサイクルフックを提供しています。
それらを適切に使用してください。
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
# リソースをセットアップ
await setup_connections()
yield {}
# クリーンアップ
await cleanup_connections()
適切に処理できていないと意図しないクラッシュがたまにありました。
原因は調査していないので、もし誰か遭遇してみたら見てみるといいかもしれません。
ブリッジでのエラーハンドリング
Pythonブリッジはエラーを適切に出力して、デバッグしやすいようにしておくと良いです。
try:
response = await chimr_connection.send_request(method, params)
if "result" in response:
return response["result"]
return {"error": "結果が返されませんでした"}
except Exception as e:
logger.error(f"{method}でエラー: {e}")
return {"error": str(e)}
Swift APIをシンプルに保つ
SwiftのHTTP Serverには、もうMCPのための複雑な実装をしなくて良いです。
Pythonからのリクエストを処理できるように実装しておきましょう。
Swift - サンプル実装
struct MCPRequest {
let method: String
let params: [String: Any]?
let id: Any
}
class SimpleMCPHandler {
func handleRequest(_ request: MCPRequest) -> [String: Any] {
// tools/call メソッドのみを処理
guard request.method == "tools/call",
let params = request.params,
let toolName = params["name"] as? String,
let arguments = params["arguments"] as? [String: Any]
else {
return ["error": "Invalid request"]
}
// ツール名に応じて処理を分岐
switch toolName {
case "get_events":
return handleGetEvents(arguments)
case "show_notification":
return handleShowNotification(arguments)
default:
return ["error": "Unknown tool: \(toolName)"]
}
}
private func handleGetEvents(_ args: [String: Any]) -> [String: Any] {
guard let date = args["date"] as? String else {
return ["error": "Missing date parameter"]
}
// CalendarServiceから実際のイベントを取得
let events = CalendarService.shared.getEvents(for: date)
return ["events": events.map { $0.toDictionary() }]
}
private func handleShowNotification(_ args: [String: Any]) -> [String: Any] {
guard let title = args["title"] as? String,
let message = args["message"] as? String
else {
return ["error": "Missing notification parameters"]
}
// 通知を表示
NotificationService.shared.show(title: title, message: message)
return ["success": true]
}
}
// HTTP Serverでの使用例
let handler = SimpleMCPHandler()
// POST /api/mcp へのリクエストを処理
if let request = MCPRequest(from: requestData) {
let response = handler.handleRequest(request)
return JSONSerialization.data(withJSONObject: response)
}
アーキテクチャの比較
さて、改めて両方の実装を振り返ってみると、以下のような比較ができます。
側面 | HTTP/SSE | FastMCPブリッジ |
---|---|---|
複雑さ | Swiftが高 | MCP/HTTP Serverともに低 |
保守性 | 困難 | やや簡単 |
標準 | カスタム | MCP準拠 |
テスト | E2E | モジュラー |
やや恣意的な表にはなりますが、FastMCPを使ったブリッジでの実装は非常に良いです。
Remote MCPが流行っていますが、ローカルで動作するアプリケーションにOAuthやOIDCはやや過剰なので、こういった方法を選択することも良さそうです。
BlenderMCP
ここまでくるとMCP Serverの実装を見ている人はわかると思いますが、BlenderMCPの実装を参考にしています。
こちらではBlenderの拡張としてHTTP Serverを実行し、同じようにPythonのMCP Serverがブリッジとして動作させています。
Claude Desktop <--(stdio)--> BlenderMCP <--(HTTP)--> BlenderMCP Extension --> Blender
今後の展望
今後は以下のようなことを検討しています。
- 目的別・言語別に実装 ... APIを公開しておけば異なる目的・言語で個別にMCP Serverを実装することも可能
- リモートでの操作 ... macOSアプリを別のマシンから操作が可能(危険)
- プラグインシステム ... 他のアプリが同じAPIを通じてChimrと統合可能(アプリ同士が通信という未来も)
まとめ
時には最良の解決策は最も直接的なものではありません。
Pythonブリッジのレイヤーを追加することで、実際にはシステム全体を簡素化し、より安全かつ簡単な仕組みにできました。
macOSアプリにMCPサポートを構築している場合、ブリッジアーキテクチャが直接実装よりも良く機能するかどうかを検討してみてください。
最初はやや面倒ですが、比較的簡単に実行することが可能です。
Discussion