macOS用常駐アプリにMCP Serverを実装する

この記事は部分的にLLMで生成している箇所があります

ネタはたまっているので、どこかのタイミングで放出していきたい K@zuki.です。

今回は久しぶりの投稿になりますが、自作しているmacOSのメニューバー常駐アプリにMCP Serverを実装する話ですが、とにもかくにも見てもらった方が早いので以下をご覧ください。

元々、常駐アプリのフルスクリーン通知で任意の文字列に置き換えれる実装を用意していました。
それをMCP Serverとして利用可能にし、Claude Codeで作業が終わった時の通知として利用可能な状態にしています。

TL;DR

  • CLIツールではなくHTTP Serverを常駐アプリに実装
  • セキュリティリスクはそこそこあるので対策は検討する必要あり

既存のアプリにMCP Serverを組み込む理由

シンプルに興味関心があったからでしかないです。
一般的なMCP ServerであればCLIツールを書けばいいだけですが、今回のケースは既存のアプリにSSEなMCP Serverを組み込むことで、

  • アプリ自体の操作
  • アプリの設定変更

といったことをClaude CodeやCursor、Claude DesktopなどのMCP Clinetから呼び出すことができます。
他にもアプリの特定部分のみを流用(フルスクリーン通知など)することができるので、場合によっては便利です。

実装方針

MCP Serverを実装するにはいくつかのパターンがありますが、現時点だと大まかに

の2種類が存在します。
一般的にはCLIでServerを実装しますが、今になって思いましたがCLIで実装しておくのが無難だと思います。 が、常駐アプリ側でListenするのが面倒くさかったのでサクッと作れるHTTP Serverを立てることにしました。

仕組み

大まかな仕組みとしては

  1. mcp-remoteを呼び出し
  2. mcp-remote経由で常駐アプリで起動しているHTTP Serverへリクエス
  3. 常駐アプリで何らかの処理を行なう

といった流れになっています。 シーケンス図で表現すると以下のような形です。

Claude CodeやClaude Desktopでは、CLIしか対応していないため、mcp-remoteを使ってHTTP Serverと通信する方式をとっています。

実装

SwiftUIで自動起動する場合には以下のように定義しておき、

  import SwiftUI

  @main
  struct MyApp: App {
      @StateObject private var mcpServer = SimpleMCPServerWrapper()

      var body: some Scene {
          WindowGroup {
              ContentView()
          }
      }
  }

  class SimpleMCPServerWrapper: ObservableObject {
      private let server = SimpleMCPServer()

      init() {
          server.start()
      }

      deinit {
          server.stop()
      }
  }
  */

MCP Serverを以下のように定義しておくと動かすことができます。
コードを見てもらえるとわかると思いますが、想定よりも多くの作業が必要になるので、適宜AIに頼るといいでしょう。

 import Foundation
  import Network

  // シンプルなMCPサーバーの実装例
  class SimpleMCPServer {
      private var listener: NWListener?
      private let port: UInt16 = 8080

      func start() {
          let parameters = NWParameters.tcp
          parameters.allowLocalEndpointReuse = true

          guard let listener = try? NWListener(using: parameters, on:
  NWEndpoint.Port(integerLiteral: port)) else {
              print("Failed to create listener")
              return
          }

          self.listener = listener

          listener.newConnectionHandler = { [weak self] connection in
              self?.handleConnection(connection)
          }

          listener.start(queue: .main)
          print("MCP Server started on port \(port)")
      }

      func stop() {
          listener?.cancel()
          listener = nil
          print("MCP Server stopped")
      }

      private func handleConnection(_ connection: NWConnection) {
          connection.start(queue: .main)

          // HTTPリクエストを読み取る
          connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak
  self] data, _, isComplete, error in
              guard let data = data, error == nil else {
                  connection.cancel()
                  return
              }

              if let request = String(data: data, encoding: .utf8) {
                  print("Received request:\n\(request)")

                  // SSE接続の場合
                  if request.contains("GET /sse") {
                      self?.handleSSEConnection(connection)
                  }
                  // JSON-RPCリクエストの場合
                  else if request.contains("POST /") {
                      self?.handleJSONRPCRequest(connection, requestData: data)
                  }
              }

              if isComplete {
                  connection.cancel()
              }
          }
      }

      private func handleSSEConnection(_ connection: NWConnection) {
          // SSEヘッダーを送信
          let headers = """
          HTTP/1.1 200 OK\r
          Content-Type: text/event-stream\r
          Cache-Control: no-cache\r
          Connection: keep-alive\r
          Access-Control-Allow-Origin: *\r
          \r

          """

          connection.send(content: headers.data(using: .utf8), completion:
  .contentProcessed { _ in
              print("SSE connection established")
          })

          // 初期接続イベントを送信
          let connectEvent = "event: connected\ndata: {\"status\": \"connected\"}\n\n"
          connection.send(content: connectEvent.data(using: .utf8), completion:
  .contentProcessed { _ in })
      }

      private func handleJSONRPCRequest(_ connection: NWConnection, requestData: Data)
  {
          // HTTPボディを抽出(簡易的な実装)
          if let requestString = String(data: requestData, encoding: .utf8),
             let bodyStart = requestString.range(of: "\r\n\r\n") {
              let bodyData = String(requestString[bodyStart.upperBound...]).data(using:
   .utf8) ?? Data()

              // JSON-RPCリクエストをパース
              if let json = try? JSONSerialization.jsonObject(with: bodyData) as?
  [String: Any],
                 let method = json["method"] as? String,
                 let id = json["id"] {

                  let response: [String: Any]

                  switch method {
                  case "initialize":
                      response = [
                          "jsonrpc": "2.0",
                          "result": [
                              "protocolVersion": "2024-11-05",
                              "serverInfo": [
                                  "name": "simple-mcp-server",
                                  "version": "1.0.0"
                              ],
                              "capabilities": [
                                  "tools": [:],
                                  "resources": [:]
                              ]
                          ],
                          "id": id
                      ]

                  case "tools/list":
                      response = [
                          "jsonrpc": "2.0",
                          "result": [
                              "tools": [[
                                  "name": "hello",
                                  "description": "Returns Hello, World!",
                                  "inputSchema": [
                                      "type": "object",
                                      "properties": [:]
                                  ]
                              ]]
                          ],
                          "id": id
                      ]

                  case "tools/call":
                      if let params = json["params"] as? [String: Any],
                         let toolName = params["name"] as? String,
                         toolName == "hello" {
                          response = [
                              "jsonrpc": "2.0",
                              "result": [
                                  "content": [[
                                      "type": "text",
                                      "text": "Hello, World! from MCP Server"
                                  ]]
                              ],
                              "id": id
                          ]
                      } else {
                          response = [
                              "jsonrpc": "2.0",
                              "error": [
                                  "code": -32601,
                                  "message": "Method not found"
                              ],
                              "id": id
                          ]
                      }

                  default:
                      response = [
                          "jsonrpc": "2.0",
                          "error": [
                              "code": -32601,
                              "message": "Method not found"
                          ],
                          "id": id
                      ]
                  }

                  // レスポンスを送信
                  if let responseData = try? JSONSerialization.data(withJSONObject:
  response),
                     let responseString = String(data: responseData, encoding: .utf8) {

                      let httpResponse = """
                      HTTP/1.1 200 OK\r
                      Content-Type: application/json\r
                      Content-Length: \(responseData.count)\r
                      Access-Control-Allow-Origin: *\r
                      \r
                      \(responseString)
                      """

                      connection.send(content: httpResponse.data(using: .utf8),
  completion: .contentProcessed { _ in
                          connection.cancel()
                      })
                  }
              }
          }
      }
  }

Claude Codeから呼び出す

Claude Codeから呼び出す場合には、以下のような設定をします。

  {
    "mcpServers": {
      "hello-world": {
        "command": "npx",
        "args": [
          "mcp-remote",
          "http://localhost:8080/sse"
        ]
      }
    }
  }

あとは適切に設定されていれば呼び出すことが可能になり、冒頭でも紹介したようにアプリで用意した機能を利用することができます。

セキュリティ

HTTP Serverとして公開ということはある程度のセキュリティリスクがあることに注意が必要です。

1. ローカルホストバインディング

デフォルトではlocalhost127.0.0.1)にバインドしていますが、誤って0.0.0.0にバインドすると外部からアクセス可能になります。

// 安全:ローカルホストのみ
let listener = try? NWListener(using: parameters, on: NWEndpoint.Port(integerLiteral: port))

// 危険:全てのインターフェース
// let listener = try? NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port)!)

2. 認証の欠如

現在の実装では認証機能がないため、ローカルの任意のプロセスがMCPサーバーにアクセスできます。
必要に応じていくつかの認証を検討してください。

  • APIキーベースの認証
  • トークンベースの認証
  • 接続元プロセスの検証

3. CORS設定

今回のケースでは考慮しなくても問題ないですが、場合によってはCORSを考える必要があります。
Access-Control-Allow-Origin*は開発時は便利ですが、本番環境では特定のオリジンのみ許可すべきで全開放するのは望ましくありません。

4. 入力検証

JSON-RPCリクエストの入力値を適切に検証し、インジェクション攻撃を防ぐ必要があります。

5. リソース制限

他にもアプリやPCによっては負荷が高まるので注意が必要です。

  • 同時接続数の制限
  • リクエストサイズの制限
  • レート制限

さいごに

今回はmacOS用常駐アプリにMCP Serverを組み込む話でした。
恐らくリリースする頃にはCLIに切り替えているかと思いますが、既存の常駐アプリでMCP ServerのI/Fを用意しておきたい場合の一例にしておいてください。
今回のように実装しておくことで、比較的簡単に自分のアプリをMCP Serverとすることが可能になります。
設定変更の容易化も狙えますし、現段階ではかゆいところに手が届かない機能を利用することや、アプリの特定機能操作も行えるので非常に便利です。

MCP Serverを組み込んでいるアプリは、週末にはベータテスターを募集する予定なので、興味がある人はTwitterかブログをウォッチしておいてください。