5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実はSwiftでもMCPサーバーを作れるんです!macOSのアプリ・ウィンドウ管理を自動化した話

Last updated at Posted at 2025-12-18

はじめに

MCPサーバーといえばPythonかTypeScriptで実装するイメージがありますが、実は公式で Swift SDK も提供されています。

Swiftの強みの一つは、macOSのAPIを簡単に利用できることです。
例えば、NSWorkspaceやAccessibility API、CGWindowList APIなど、macOSのAPIを直接利用できます。他の言語だとAppleScriptを介す必要がありますが、Swiftだとそのようなオーバーヘッドはありません。

今回は仕事効率化のためのMCPサーバーを作りたいと思い、Swiftでこんなものを作ってみました。

macos_workspace_mcp_server_demo.gif

AIエージェントに自然言語で指示を出すだけで、アプリの起動やウィンドウ配置ができます!

本記事では、作成したMCPサーバーの機能や具体的なコードを紹介します。
SwiftでMCPサーバーを作るきっかけや参考になれば幸いです。

MCPって何?という方は、みのるんさんの やさしいMCP入門 が非常にわかりやすいのでこちらをオススメします。

対象読者

この記事は以下のような方を対象にしています。

  • MCPサーバーを自作してみたい人
  • macOSの自動化に興味がある人
  • Swiftでツール開発をしてみたい人

きっかけ

仕事効率化への課題

普段の業務でmacOSを使っていて、アプリやウィンドウの管理が煩雑だと感じていました。作業中にアプリを何個も立ち上げっぱなしにしてしまったり、ウィンドウの位置がバラバラになって手動で整えたりすることが多々ありました。
私は基本的にキーボードでPCを操作することが好きなのですが、ことウィンドウの配置においてはどうしてもマウスを使わざるを得なくてなんとかしたいと思っていました。

既存ツールが見つからなかった

macOSを操作するMCPサーバーは他にもありますが、自分の調べた限りアプリとウィンドウの配置・位置管理に特化したものは見当たりませんでした。

参考:macOS操作系のMCPサーバー

「ないなら作ろう」ということで、自作することにしました。

MCPサーバーを作ってみたかった

一番の動機はこれですね笑

MCPサーバーの実装言語はPythonやTypeScriptが主流ですが、私はiOSアプリエンジニアとして普段からSwiftを使っています。慣れ親しんだ言語で書けるなら、それが一番効率的ですよね。

また前述のとおり、SwiftだとmacOS APIへのネイティブアクセスが可能であるため生成AIの力を借りれば実装もそこまで難しくないだろうと思っていました。1

機能

今回作ったMCPサーバー「macOS Workspace MCP Server」では、以下の7つのツールを提供しています。

ツール名 機能
launch_application 指定したアプリを起動
quit_application 指定したアプリを終了
list_applications 起動中のアプリ一覧を取得
list_windows ウィンドウ一覧を取得
position_window ウィンドウを指定位置に配置
focus_window 指定ウィンドウを最前面に表示
list_displays 接続中のディスプレイ情報を取得

実際にClaude Desktopで使ってみると、以下のような感じで操作できます。

image.png

また実装はGitHub Repositoryとして公開しています。詳細を確認したい方はこちらを参照いただければと思います。

実装

ここではmacOS Workspace MCP Serverがどのように動いているかを簡単に紹介します。

アーキテクチャ

今回実装したMCPサーバーは以下の4レイヤーで構成されています。

レイヤー 役割
Entry Point MCPサーバー初期化、ハンドラー登録
Tool Registry ツール一覧管理、ルーティング
Tools MCP入出力処理、引数バリデーション
Services macOS API呼び出し、ビジネスロジック

以降、各レイヤーのコードを順に見ていきます。

Entry Point(Main.swift)

MCPサーバーの起点となるコードです。
MCP Swift SDKの Server を初期化し、tools/listtools/call のハンドラーを登録して起動します。

Main.swift
@main
struct MacOSWorkspaceMCPServer {
    static func main() async {
        do {
            let server = await Server(
                name: "MacOSWorkspaceMCPServer",
                version: "0.1.0",
                capabilities: .init(tools: .init(listChanged: false))
            )
            // ツール一覧を返すハンドラー
            .withMethodHandler(ListTools.self) { params in
                ToolRegistry.handleListTools(params)
            }
            // ツール実行ハンドラー
            .withMethodHandler(CallTool.self) { params in
                await ToolRegistry.handleCallTool(params)
            }

            // 標準入出力でJSON-RPC通信
            let transport = StdioTransport()
            try await server.start(transport: transport)

            // サーバー実行継続
            while true {
                try await Task.sleep(for: .seconds(1))
            }
        } catch {
            FileHandle.standardError.write("Error: \(error)\n".data(using: .utf8)!)
            exit(1)
        }
    }
}

Tool Registry(ToolRegistry.swift)

Tool RegistryはMCPツールの一覧管理とルーティングを担当します。
tools/list が呼ばれるとツール定義一覧を返し、tools/call が呼ばれると該当ツールにルーティングします。

ToolRegistry.swift
public enum ToolRegistry {
    /// 登録済みツール一覧
    private static let registeredTools: [any MCPTool.Type] = [
        LaunchApplicationTool.self,
        QuitApplicationTool.self,
        ListApplicationsTool.self,
        ListWindowsTool.self,
        PositionWindowTool.self,
        FocusWindowTool.self,
        ListDisplaysTool.self,
    ]

    /// tools/list ハンドラー
    public static func handleListTools(_ params: ListTools.Parameters) -> ListTools.Result {
        let definitions = registeredTools.map { $0.definition }
        return .init(tools: definitions, nextCursor: nil)
    }

    /// tools/call ハンドラー
    public static func handleCallTool(_ params: CallTool.Parameters) async -> CallTool.Result {
        let arguments = params.arguments ?? [:]

        switch params.name {
        case LaunchApplicationTool.name:
            let service = DefaultApplicationService()
            return await LaunchApplicationTool(service: service).execute(arguments: arguments)

        // ... 他のツールも同様にルーティング

        default:
            return .init(content: [.text("Unknown tool: \(params.name)")], isError: true)
        }
    }
}

Tools(LaunchApplicationTool.swift 等)

各MCPツールの入出力処理を担当するレイヤーです。
各ツールを MCPTool プロトコルに準拠させることで、MCPツールとして必要な情報を定義しながら異なるMCPツールに対して共通の方法で呼び出せるようにしています。

MCPTool.swift
public protocol MCPTool: Sendable {
    /// ツール名(MCP準拠のスネークケース)
    static var name: String { get }

    /// ツール定義(Tool型)
    static var definition: Tool { get }

    /// ツール実行
    /// - Parameter arguments: ツール引数
    /// - Returns: 実行結果
    func execute(arguments: [String: Value]) async -> CallTool.Result
}

例えば、指定したアプリを起動するツールであるlaunch_applicationの実装は以下のようになります。

LaunchApplicationTool.swift
public struct LaunchApplicationTool: MCPTool {
    public static let name = "launch_application"

    public static let definition = Tool(
        name: name,
        description: """
            指定されたbundle IDのアプリケーションを起動します。
            アプリが既に起動している場合はアクティブ化(最前面化)します。
            成功時にはプロセスIDとアプリケーション名を返します。
            """,
        inputSchema: .object([
            "type": .string("object"),
            "properties": .object([
                "bundleId": .object([
                    "type": .string("string"),
                    "description": .string("アプリケーションのbundle ID(例: com.apple.Safari)"),
                ])
            ]),
            "required": .array([.string("bundleId")]),
        ])
    )

    /// ツール入力パラメーター
    struct Input: Decodable {
        let bundleId: String?
    }

    private let service: ApplicationServiceProtocol

    public func execute(arguments: [String: Value]) async -> CallTool.Result {
        // 1. 引数をデコード
        guard let input: Input = MCPArgumentDecoder.decode(from: arguments) else {
            return .init(content: [.text("エラー: 引数のデコードに失敗しました。")], isError: true)
        }

        // 2. バリデーション
        guard let bundleId = input.bundleId, !bundleId.isEmpty else {
            return .init(content: [.text("パラメーター 'bundleId' が必要です")], isError: true)
        }

        // 3. サービス呼び出し
        do {
            let result = try await service.launchApplication(bundleId: bundleId)
            let response = LaunchApplicationResponse(from: result)
            // 4. 結果をエンコード
            return MCPResultEncoder.encode(response)
        } catch let error as WorkspaceError {
            return .init(content: [.text(error.userMessage)], isError: true)
        } catch {
            return .init(content: [.text("予期しないエラー: \(error.localizedDescription)")], isError: true)
        }
    }
}

Services(ApplicationService.swift 等)

Servicesレイヤーは実際のmacOS API呼び出しを担当します。
ツールから分離することでテスト容易性を向上させています。
例えば、Toolsで紹介したlaunch_applicationが使用しているServiceであるApplicationServiceは以下のようになります。

ApplicationService.swift
public protocol ApplicationServiceProtocol: Sendable {
    func launchApplication(bundleId: String) async throws -> LaunchResult
    func quitApplication(bundleId: String) async throws -> QuitResult
    func listRunningApplications() async -> [ApplicationInfo]
}

public final class DefaultApplicationService: ApplicationServiceProtocol {

    public func launchApplication(bundleId: String) async throws -> LaunchResult {
        let workspace = NSWorkspace.shared

        // 既に起動しているか確認
        if let runningApp = workspace.runningApplications.first(where: {
            $0.bundleIdentifier == bundleId
        }) {
            runningApp.activate()  // アクティブ化
            return LaunchResult(
                processId: Int(runningApp.processIdentifier),
                appName: runningApp.localizedName ?? bundleId,
                wasAlreadyRunning: true
            )
        }

        // アプリケーションのURLを取得して起動
        guard let appURL = workspace.urlForApplication(withBundleIdentifier: bundleId) else {
            throw WorkspaceError.applicationNotFound(bundleId: bundleId)
        }

        let runningApp = try await workspace.openApplication(
            at: appURL, 
            configuration: NSWorkspace.OpenConfiguration()
        )

        return LaunchResult(
            processId: Int(runningApp.processIdentifier),
            appName: runningApp.localizedName ?? bundleId,
            wasAlreadyRunning: false
        )
    }

    // quitApplication, listRunningApplications も同様に実装
}

使い方

この章は、今回作成したMCPサーバーをとりあえず使ってみたいという方向けの内容です。
興味のある方だけ見ていただき、そうでない方は読み飛ばしていただいても構いません。

前提条件

  • macOS 15.0以上
  • Swift 6.0

インストール

# リポジトリをクローン
git clone https://github.com/rukawa07/macos-workspace-mcp-server
cd macos-workspace-mcp-server

# ビルド
swift build -c release

MCP Client側の設定

ここではMCP Clientの例として、Claude Desktopの設定ファイルにMCPサーバーを追加する方法を紹介します。

設定ファイルの場所: ~/Library/Application Support/Claude/claude_desktop_config.json

{
  "mcpServers": {
    "workspace": {
      "command": "/path/to/macos-workspace-mcp-server/.build/release/MacOSWorkspaceMCPServer"
    }
  }
}

/path/to/ の部分は、実際にクローンしたディレクトリのパスに置き換えてください。

アクセシビリティ権限の付与

ウィンドウ操作にはアクセシビリティ権限が必要です。

  1. システム設定 を開く
  2. プライバシーとセキュリティ > アクセシビリティ を選択
  3. Claude Desktop にチェックを入れる2
  4. Claude Desktopを再起動

おわりに

今回は、SwiftでmacOSのウィンドウ管理MCPサーバーを作ってみました。

所感として、PythonやTypeScriptでなくても公式のSDKさえあれば簡単にMCPサーバーを実装できると感じました。
また、MCPサーバーで何かを実現したいときに、操作対象に適した言語をMCPサーバーの実装言語として選択するのも良いアプローチかなと思いました。

今回作ったMCPサーバーはまだプロトタイプなので、今後の展望としては、複数のアプリやウィンドウを同時に制御する際の速度改善や、iOS SimulatorやGitHub Copilot for Xcodeのようにサイズに制約があるウィンドウへの対応などに取り組めればと考えています。

参考資料

  1. 今回はcc-sddでMCPサーバーを実装しました。macOSのAPIの使用だけでなく、MCPサーバー自体の実装は初めてでしたが思ったより短い時間で楽しく実装できました。個人的にはこの開発体験を通じて色々学びがあったので、もしかしたら別記事で書くかもしれません。

  2. 一度でもMCPサーバーを起動している場合はMacOSWorkspaceMCPServerもチェック可能になっていると思います。もしうまくいかない場合はこちらもチェックを入れてみてください。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?