スパイダープラス Tech Blog

建設SaaS「スパイダープラス」のエンジニアとデザイナーのブログ

AIでDBを操作 ~ Cursor×MCPでSwiftDataを自然言語で扱う方法


スパイダープラスで iOS アプリの開発を担当している荒井と申します。仕事としてはもちろん、会社外でも趣味としてiOS アプリ開発を続けており、日常的に Swift や周辺の技術に触れています。

これまで趣味で開発していたアプリにはRealmを利用していましたが、近年は Apple が提供するSwiftDataをメインに採用するようになりました。SwiftDataは宣言的で使いやすく、Apple 製ならではの統合感も魅力ですが、登場して日が浅いため「実際の開発では不便だな」と感じる場面も少なくありません。

 本記事で扱う内容

今回はCursor と自作 MCP サーバーを連携させて、SwiftData を自然言語で直接操作できる仕組みを作ってみました。

私が日々の開発の中で「こんな機能があればもっと楽になるのに」と思ったポイントを取り上げ、それを解決するための工夫や実践例をご紹介します。

本記事でやること

  • iOS アプリに Swifter を組み込み、/debug/notes などの API を実装(ノートの取得/追加/更新が可能)
  • Node.js で MCP サーバー(ios-debug-bridge) を作成し、HTTP API を仲介できるようにする
  • Cursor の mcp.json 設定に追加し、自然言語で「ノートを5件追加」「長文だけ取得」「このIDを削除」といった指示を実行

本記事を読み終えると、「Cursor がそのまま SwiftData の管理画面になる」体験を再現できることを目指します。

SwiftData を使った検証やデバッグ作業を効率化したい方におすすめです。
例えば、デバッグ時の初期データ投入や負荷テスト用の大量データ生成、長文を作って UI の見切れやスクロール挙動をチェックするといった、開発中によく出てくる面倒な作業も自然言語だけで手早くできるようになります。

注意点

この記事で紹介する仕組みは SwiftData を自然言語で操作できる強力な方法ですが、いくつか注意しておきたいポイントもあります。  

まず、誤操作のリスクに注意が必要です。「削除して」と言ったつもりが、解釈の違いで一括削除が走る…なんてこともゼロではありません。そのため、検証は必ずテスト用データ/ダミーデータで行いましょう。  

また、セキュリティやアクセス制御を入れないまま業務データを扱うのは危険です。本記事では「開発中の実験」や「検証用のツール」としての使い方を想定しています。本番データにそのまま使うのはおすすめしません。  

さらに、使用する AI モデルや環境によって、返される内容が異なる場合があります。必ず自分の環境で確認しながら進めてください。

💡 補足  

Cursor では MCPツールの特定機能だけを無効化し、本当に必要な時だけ有効化することが可能です。  
例えば「削除機能」を普段は無効にしておき、必要な時だけオンにする、といった運用ができます。  
誤操作を避けるため、この方法を強く推奨します。

記事では説明しない内容について

なお、本記事では以下のような 基本操作や環境構築の手順 については取り扱っていません。

  • Xcode の基本操作(プロジェクト作成、ビルド/実行方法、SwiftData の基礎チュートリアル、SPM、Swifterの使い方など)
  • Cursor の使い方(インストール、基本的なプロンプト操作やコード補完の仕組み)
  • Node.js/npm の初歩(インストールや npm init などの標準的な流れ)

これらを既に習得している方を前提にしています。
記事の主眼は、Cursor と MCP サーバーを連携させ、SwiftDataを自然言語で操作できるようにする具体的な方法に絞っています。

検証環境

  • macOS 15.6(Apple Silicon)
  • Xcode 16.1/iOS シミュレータ 18.1
  • Swift 6.0.2(SwiftData:Xcode 16系に付属)
  • Node.js v24.6.0 /npm 11.5.1
  • Cursor 1.4.5
  • Swifter 1.5.0
  • @modelcontextprotocol/sdk 1.17.4

構成図

以下に今回の仕組みの全体構成図を示します。この図をもとに、処理の流れを説明していきます。

注釈

ios-debug-bridge:自然言語API呼び出しを仲介 (今回実装)  
②Swifterサーバー:SwiftDataを操作するためのローカルAPI (今回実装)

SwiftDataについて

SwiftDataは Core Data の後継として登場した、宣言的にデータモデルを扱えるフレームワークです。

宣言的に書けるためコードがシンプルになり、簡単にデータを永続化できるのが大きな魅力です。特に個人開発やプロトタイプなど「とりあえず動くものをサッと作りたい」ケースでは非常に重宝します。

一方で、登場して間もないフレームワークであるがゆえに、専用の管理ツールがまだ十分に整備されていないという課題もあります。

そのため、開発中にデータを確認・編集したい場合には、Xcode のコンソールでの結果を出力して目視確認するなど、やや原始的な方法に頼らざるを得ないのが現状です。また、初期データの投入や大量データの生成といったシーンでも「もっと簡単にできればいいのに」と不便を感じる場面があります。

Cursorについて

最近はCursorというエディターを使い始めました。知らない方に簡単に紹介すると、エディター上から AI に自然言語で指示を出すことで、コードの追加や修正を自動で行ってくれる開発支援ツールです。

さらに Cursor はMCP (Model Context Protocol) サーバーにも対応しており、エージェントが GitHubFigma といった外部サービスのデータを参照しながら回答できる仕組みを備えています。

今回の記事では、この MCPサーバーを自作し、エージェントを経由して SwiftDataに保存されたデータを「参照」「編集」「追加」できるようにしてみました。

セキュリティを意識した設計を心がければ、SwiftData に限らず他のデータベースへの応用も可能です。本記事がその一例として参考になれば幸いです。

MCPサーバーについて

先ほど紹介した MCPサーバーですが、「サーバー」と聞くと AWSGCP 上に本格的な環境を立てるイメージを持つ方も多いかもしれません。

しかし今回取り上げる MCPサーバーは、あくまで開発者用のローカル環境で動かすものです。特別なクラウド環境や大掛かりなセットアップは不要で、手元の Mac 上ですぐに試すことができます。

ここからは実際に、MCPサーバーを構築していく手順を順番に見ていきましょう。

CursorとMCPサーバー連携の魅力

Cursor と自作した MCPサーバーを連携することで、専用の GUI がなくてもCursor 上から SwiftData を検索・編集できるようになります。

専用ツールが存在しない SwiftData にとって、Cursor がそのまま管理画面の役割を担ってくれるわけです。

基本操作の例

  • 「ノート一覧を取得して」 → MCPサーバーがSwiftDataのデータベースから結果を返す
  • 「このノートを削除して」 → MCPサーバー経由でレコードを削除

一括処理の例

  • 「全てのデータを削除」 → SwiftDataの全レコードをクリア
  • 「100文字以上のノートを削除」 → 条件に一致するノートを一括削除
  • 「英語のノートを10件作成して」 → 指定条件に沿ったノートを自動生成
  • 「すべてのノートを日本語から英語に翻訳して」 → 既存レコードの本文を翻訳し、内容を更新

このように、本来なら専用の管理ツールやスクリプトが必要な処理も、自然言語だけで直感的に操作できるのがMCPサーバーの大きな魅力です。

それでは、具体的にコードの実装に入っていきましょう。

全体概要

今回作成したアプリは、SwiftUI + SwiftData で作ったシンプルなノート管理アプリに、Swifterを用いたローカルHTTPサーバーを組み込み、MCPサーバーなど外部からデータを操作できるようにしたものです。

具体的には次のような構成になっています。

  • SwiftUI
    ノート一覧・追加・編集のための基本的なUIを提供します。
  • SwiftData
    ノート(Note モデル)を永続化。UUID、本文(text)、作成日時を持ち、アプリを終了しても削除されず、次回起動時にも利用できます。
  • Swifter (HTTP サーバー)
    アプリ内でローカルサーバーを起動し、/debug/* の内部 API を提供します。 
    • /debug/health : サーバー動作確認
    • /debug/notes : 保存されている全てのノートを JSON で返す
    • /debug/add_note : 新しいノートを追加
    • /debug/edit_note : 既存ノートを更新
    • /debug/delete_note : 指定した ID のノートを削除

仕組みのポイント

  1. iOSアプリを起動するとローカルでHTTPサーバーが立ち上がる(DEBUG のみ)
  2. 外部ツール(CursorやcurlMac側のブラウザ)から http://localhost:8080/debug/ を直接呼び出せる(トークン認証あり)
  3. リクエストに応じてSwiftDataにアクセスし、結果をJSONで返す

 iOS

まずは iOS 側の実装から始めます。Swifterを使ってアプリ内に軽量な HTTP サーバーを立ち上げ、/debug/health で疎通確認してみましょう。

 1. Swifterを追加

  • Xcode → File > Add Packages…
  • Search or Enter Package URL に以下を入力して追加

   https://github.com/httpswift/swifter.git

2. サンプルコードを貼り付ける

次に以下のコードを ContentView.swift にコピーして実行してください。

    swift
import SwiftUI
import SwiftData
import Swifter

@main
struct NoteApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Note.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        
        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("ModelContainerの生成に失敗しました。: \(error)")
        }
    }()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]
    private let server = HttpServer()
    @State private var isPresentingNew = false
    @State private var editNote: Note? = nil
    @State private var serverStarted = false
    // ios_debug_bridge.mjs側のtokenも同じ値に合わせてください
    private let token = "sk_test_51N8WJEXYhQ8t9AbcD3fGh7JKlMnoPQ4r5sTuvWxYZaBcDeFgHiJkLmNoPqR"
    
    var body: some View {
        VStack {
            NavigationStack {
                ZStack {
                    if notes.isEmpty {
                        Text("未登録")
                    } else {
                        List {
                            ForEach(notes) { note in
                                Text(note.text)
                                    .foregroundStyle(.primary)
                                    .contentShape(Rectangle())
                                    .onTapGesture { editNote = note }
                                    .swipeActions(edge: .trailing) {
                                        Button(role: .destructive) {
                                            do {
                                                try delete(id: note.id)
                                            } catch {
                                                print("削除に失敗しました")
                                            }
                                        } label: {
                                            Label("削除", systemImage: "trash")
                                        }
                                    }
                            }
                        }
                    }
                }
                .navigationTitle("ノート")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button { isPresentingNew = true } label: { Image(systemName: "plus") }
                    }
                }
            }
            .sheet(isPresented: $isPresentingNew) {
                NoteForm(text: "") { addText in
                    do {
                        try add(text: addText)
                    } catch {
                        print("追加に失敗しました")
                    }
                    isPresentingNew = false
                }
            }
            .sheet(item: $editNote) { note in
                NoteForm(text: note.text) { editText in
                    note.text = editText
                    try? modelContext.save()
                    editNote = nil
                }
            }
            .task {
#if DEBUG
                // 2重で起動するのを防ぐ
                if !serverStarted {
                    initServer()
                    serverStarted = true
                }
#endif
            }
        }
    }
}

extension ContentView {
    struct NoteForm: View {
        @State private var note: String
        var onSave: (String) -> Void
        
        init(text: String, onSave: @escaping (String) -> Void) {
            self._note = State(initialValue: text)
            self.onSave = onSave
        }
        
        var body: some View {
            VStack {
                Text("ノート")
                    .font(.title)
                TextEditor(text: $note)
                    .border(.gray)
                Button("保存") {
                    onSave(note)
                }
                .disabled(note.isEmpty)
                
                Spacer()
            }
            .padding()
        }
    }
}

extension HttpRequest {
    var toJson: [String: Any]? {
        return (try? JSONSerialization.jsonObject(with: Data(self.body))) as? [String: Any]
    }
}

// 内部API
extension ContentView {
    private func json(_ body: [String: Any], status: Int = 200) -> HttpResponse {
        .raw(status, status < 400 ? "OK" : "ERR",
             ["Content-Type": "application/json; charset=utf-8",
              "Cache-Control": "no-store"]) { w in
            let data = try JSONSerialization.data(withJSONObject: body)
            try w.write(data)
        }
    }
    @inline(__always)
    private func onMain(_ work: @escaping @MainActor () -> T) -> T {
        if Thread.isMainThread { return work() }
        return DispatchQueue.main.sync { work() }
    }
    
    private func initServer() {
        server.POST["/debug/add_note"] = { req in
            if let unauth = requireAuth(req) { return unauth }
            guard
                let json = req.toJson,
                let text = json["text"] as? String, !text.isEmpty
            else {
                return .notAcceptable
            }
            return onMain {
                do {
                    try add(text: text)
                    return .ok(.json(["ok": true]))
                } catch {
                    return .notAcceptable
                }
            }
        }
        
        server["/debug/health"] = { req in
            if let unauth = requireAuth(req) { return unauth }
            return .ok(.json(["ok": true]))
        }
        
        server["/debug/notes"] = { req in
            if let unauth = requireAuth(req) { return unauth }
            return onMain {
                let notes = notes.map {
                    ["id": $0.id.uuidString,
                     "text": $0.text,
                     "createdAt": ISO8601DateFormatter().string(from: $0.createdAt)]
                }
                return .ok(.json(notes))
            }
        }
        
        server.POST["/debug/delete_note"] = { req in
            if let unauth = requireAuth(req) { return unauth }
            return onMain {
                guard
                    let json = req.toJson,
                    let id = json["id"] as? String,
                    let uuid = UUID(uuidString: id)
                else {
                    return self.json(["ok": false, "error": #"invalid body. require { "id": "" }"#], status: 400)
                }
                do {
                    try delete(id: uuid)
                    return self.json(["ok": true, "id": id, "message": "削除しました"], status: 200)
                } catch {
                    return self.json(["ok": false, "error": "not found"], status: 404)
                }
            }
        }
        
        server.POST["/debug/edit_note"] = { req in
            if let unauth = requireAuth(req) { return unauth }
            return onMain {
                guard
                    let json = req.toJson,
                    let id = json["id"] as? String,
                    let text = json["text"] as? String,
                    let uuid = UUID(uuidString: id)
                else {
                    return self.json(["ok": false,"error": #"invalid body. require { "id": "", "text": "string" }"#], status: 400)
                }
                do {
                    try edit(id: uuid, text: text)
                    return self.json(["ok": true, "id": id, "message": "更新しました"], status: 200)
                } catch {
                    return self.json(["ok": false, "error": "not found"], status: 404)
                }
            }
        }
        
        do {
            try server.start(8080, forceIPv4: true)
            print("✅ Server started on http://localhost:8080")
        } catch {
            print("❌ Swifter start failed: \(error)")
        }
    }
 
    private func requireAuth(_ req: HttpRequest) -> HttpResponse? {
        let header = req.headers["authorization"] ?? ""
        let ok = header == "Bearer \(token)"
        if ok { return nil }
        return .raw(401, "Unauthorized", [
            "Content-Type": "application/json; charset=utf-8",
            "WWW-Authenticate": "Bearer"
        ]) { w in
            let body = try! JSONSerialization.data(withJSONObject: ["ok": false, "error": "unauthorized"])
            try w.write(body)
        }
    }
}

@Model
final class Note {
    var id: UUID
    var text: String
    var createdAt: Date
    
    init(text: String) {
        self.id = UUID()
        self.text = text
        self.createdAt = Date()
    }
}

// SwiftDataの操作
extension ContentView {
    @MainActor
    private func add(text: String) throws {
        withAnimation {
            let newItem = Note(text: text)
            modelContext.insert(newItem)
        }
        try modelContext.save()
    }
    
    @MainActor
    private func delete(id: UUID) throws {
        withAnimation {
            if let target = notes.first(where: { $0.id == id }) {
                modelContext.delete(target)
            }
        }
        try modelContext.save()
    }
    
    @MainActor
    private func edit(id: UUID, text: String) throws {
        withAnimation {
            if let target = notes.first(where: { $0.id == id }) {
                target.text = text
            }
        }
        try modelContext.save()
    }
}

3. ビルド&実行

ビルドに成功すると、iOSシミュレータでアプリが起動し、Xcodeのコンソールに次のログがでます。

✅ Server started on http://localhost:8080

シミュレータ(iOS 18.1)/Xcode 16.1/macOS 15.6/Apple Silicon

4. 動作確認

iOSシミュレータを起動した状態で、Macのターミナルからcurlでアクセスしてみてください

curl -H "Authorization: Bearer sk_test_51N8WJEXYhQ8t9AbcD3fGh7JKlMnoPQ4r5sTuvWxYZaBcDeFgHiJkLmNoPqR" \
     http://localhost:8080/debug/health

レスポンスとして次のJSONが表示されれば成功です 🎉

json {"ok":true}

MCPサーバーの実装

次に、Cursorと連携するMCPサーバーを実装します。

package.jsonを作成

{
  "name": "ios-debug-bridge",
  "version": "1.0.0",
  "type": "module",
  "description": "MCP server for iOS debug bridge",
  "license": "ISC",
  "private": true,
  "engines": { "node": ">=18.17" },
  "scripts": {
    "dev": "node ./ios_debug_bridge.mjs",
    "check": "node -e \"console.error(process.versions)\""
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.17.4"
  }
}

ios_debug_bridge.mjsを実装

    
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// 設定(Swift側のコードを元に合わせてください)
const APP_BASE = "http://127.0.0.1:8080";
const TOKEN = "sk_test_51N8WJEXYhQ8t9AbcD3fGh7JKlMnoPQ4r5sTuvWxYZaBcDeFgHiJkLmNoPqR"

process.on("uncaughtException", e => console.error("❌ Uncaught:", e));
process.on("unhandledRejection", e => console.error("❌ Unhandled:", e));
console.error("🚀 ios-debug-bridge starting…", {
  APP_BASE, node: process.versions.node, hasFetch: typeof fetch !== "undefined"
});

// Get
async function httpGet(path, params = {}) {
  const url = new URL(path, APP_BASE);
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
  }
  const r = await fetch(url, { method: "GET", 
    headers: { Authorization: `Bearer ${TOKEN}` }
  });
  const text = await r.text().catch(() => "");
  if (!r.ok) throw new Error(`GET ${url} -> ${r.status} ${r.statusText}\n${text}`);
  return text;  
}

// Post
async function httpPost(path, body = {}) {
  const url = new URL(path, APP_BASE);

  const r = await fetch(url, { 
    method: "POST", 
    headers: { Authorization: `Bearer ${TOKEN}`},
    body: JSON.stringify(body)
   }
  );
  const text = await r.text().catch(() => "");
  if (!r.ok) throw new Error(`POST ${url} -> ${r.status} ${r.statusText}\n${text}`);
  return text;
}

const server = new McpServer({ name: "ios-debug-bridge", version: "0.4.0" });
const asToolResult = (data) => ({ content: [{ type: "text", text: String(data) }] });

// ヘルスチェック
server.registerTool(
  "health",
  { title: "Health", description: "Call /debug/health on iOS app" },
  async () => {
    try {
      const res = await httpGet("/debug/health");
      return asToolResult(res);
    } catch (e) {
      console.error("❌ health error:", e);
      return asToolResult(`ERROR: ${String(e)}`);
    }
  }
);

// ノート一覧
server.registerTool(
  "list_notes",
  { title: "List Notes", description: "GET /debug/notes?limit&offset" },
  async (input = {}) => {
    try {
      const { limit, offset } = input; // 未指定ならサーバ側デフォルトに任せる
      const q = {};
      if (limit  !== undefined) q.limit  = limit;
      if (offset !== undefined) q.offset = offset;
      const res = await httpGet("/debug/notes", q);
      return asToolResult(res);
    } catch (e) {
      console.error("❌ list_notes error:", e);
      return asToolResult(`ERROR: ${String(e)}`);
    }
  }
);

// ノート追加
server.registerTool("add note",
  {
    title: "add note",
    description: "add note ",
    inputSchema: { text: z.string() }
  },
  async ({ text }) => {
    const resultText = await httpPost("/debug/add_note", { text });
    return {
      content: [
        { type: "text", text: `✅ ノートの追加に成功\n${resultText}` }
      ],
    };
  }
);

// ノート削除
server.registerTool("delete note",
  {
    title: "delete note",
    description: "delete note ",
    inputSchema: { id: z.string() }
  },
  async ({ id }) => {
    await httpPost("/debug/delete_note", { id });
    return {
      content: [
        { type: "text", text: "✅ ノートの削除に成功" }
      ],
    };
  }
);

// ノート編集
server.registerTool("edit note",
  {
    title: "edit note",
    description: "edit note ",
    inputSchema: { id: z.string(), text: z.string() }
  },
  async ({ id , text}) => {
    const resultText = await httpPost("/debug/edit_note", { id, text });
    return {
      content: [
        { type: "text", text: "✅ノートの更新に成功" }
      ],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

起動コマンド

npm install npm run dev 🚀 ios-debug-bridge starting… { APP_BASE: 'http://127.0.0.1:8080', node: '24.6.0', hasFetch: true }

これをCursorにMCPサーバーとして登録すると、Cursorから「ノート一覧を出して」「ノートを追加して」と自然言語で指示できるようになります。

例)Cursorのチャットに入力

  • ios-debug-bridge に接続できる?
  • ノートを5件追加して
  • ノート一覧を表示して
  • このIDのノートを「買い物メモ」に更新して: <UUID>
  • このIDのノートを削除して: <UUID>

MCPクライアント(Cursor)にローカルMCPサーバー(ios-debug-bridge)を追加して有効化する

mcp.jsonios-debug-bridgeを登録します。argsにはios_debug_bridge.mjs絶対パスを入れてください。

mcp.json

json
{
  "mcpServers": {
    "ios-debug-bridge": {
      "command": "node",
      "args": ["/path/to/ios_debug_bridge.mjs"]
    }
  }
}

mcp.jsonを保存したら、Preferences → Cursor  Settings → MCPToolsを開きます。作成したMCPサーバー名: `ios-debug-bridge` が一覧に出ているので、トグルをONにしてください。これで準備完了です。

(環境によってはON後にCursorの再読み込み/再起動が必要になることがあります)

シミュレータ(iOS 18.1)/Xcode 16.1/macOS 15.6/Apple Silicon

動作検証

ここまでできたら、Cursorからヘルスチェックを呼び出して、サーバーが起動しているか確認しましょう。

Cursorのチャットでブリッジ経由アクセスを依頼

Cursorのチャットに、次のように話しかけます(例):

ios-debug-bridgeに接続できる?

うまくいくと、AIがブリッジ(ios-debug-bridge)を通じてヘルスチェックのURLにアクセスし、レスポンス本文として

json {"ok":true}

が表示されます。これでSwifterのAPIサーバーが起動・疎通していることが確認できます🎉

まとめて追加してみる(自然言語 → 繰り返し処理)

Cursorのチャットにこうお願いしてみてください:

「ノートを5件追加してください」

ios-debug-bridge経由でエージェントが/debug/add_noteを5回連続で呼び出し、

シミュレータ側のアプリにノートが5件追加されていくのがわかります 🎉

具体的に何が起きているか

エージェントは自然言語の指示を解釈し、以下のような HTTP POST を繰り返し送ります。

POST http://localhost:8080/debug/add_note Content-Type: application/json {"text":"Note 1"}

POST http://localhost:8080/debug/add_note Content-Type: application/json {"text":"Note 2"}

(…合計5回)

  • サーバー(Swifterはそれぞれに {"ok":true} を返し、SwiftDataに保存します。
  • シミュレータ側では、Listが自動更新され、画面に5件のノートが増えるのを確認できます。

ノートを編集してみる(自然言語 → レコード更新)

次に、自然言語で既存ノートの編集を試してみましょう。

Cursorのチャットにこうお願いしてください:

「ノート3を夏休みの思い出(150文字)に書き換えて」

こちら側で具体的な本文やIDを指定しなくても、AIが自動的にノート一覧から「ノート3」を特定し、
さらに「夏休みの思い出」をテーマにした約150文字の記事を生成して書き換えてくれました。

シミュレータ(iOS 18.1)/Xcode 16.1/macOS 15.6/Apple Silicon

実際に裏で起きていること

1. 一覧を取得

エージェント(AI)がまず GET /debug/notesにアクセスし、ノート一覧を受け取ります。    
(例:Note 1, Note 2, Note 3が返ってくる)

2. 対象ノートの特定

その結果から 「ノート3」に対応するIDを特定します。(例:"id": "A1B2C3-UUID")    
(たとえば "id": "A1B2C3..."

3. 編集 API の呼び出し

MCPサーバー経由で 編集エンドポイント にリクエストを送ります。

        POST http://localhost:8080/debug/edit_note
    Content-Type: application/json
    
    {
      "id": "A1B2C3-UUID",
      "text": "夏休みの思い出(150文字の文章…)"
    }
    

4. 更新とレスポンス

サーバー側で SwiftData の `Note` が更新され、結果が返ります。

json {"ok": true, "id": "A1B2C3-UUID", "message": "更新しました"}

ノートをAIに探させてみる

次は、保存済みのノートをAIに探させてみましょう。

Cursorのチャットに、次のようにお願いしてみてください。

「文字数が多いノートを取得して」

すると、保存されているノートの中から、文字数の多いノートが抽出されて返ってきます。
下のスクリーンショットのように、先ほど編集したノートが取得されているのを確認できます。

 

実際の流れ

1. 一覧取得

エージェントが /debug/notesにアクセスし、全ノートをJSONで受け取る。

[ {"id":"...","text":"ノート5","createdAt":"2025-08-25T08:30:00Z"}, ~ 省略 ~ {"id":"...","text":"今年の夏休みは家族と一緒に海…(長文)","createdAt":"2025-08-25T08:45:00Z"} ]

2. クライアント側で選別

返ってきた一覧から、文字数が多い(長文)ノートを特定します。

3.結果表示

エージェントが選んだノートを返答として表示してくれます。

課題と注意点

今回紹介した「Cursor × MCP × SwiftData」による自然言語操作は強力ですが、業務で扱う場合 にはいくつか注意すべき点があります。

破壊的操作の誤実行

  • 自然言語の解釈が曖昧だと、意図せず削除や一括更新といった破壊的操作が走る可能性があります。
  • 削除・更新時の確認ステップ、dry-run(プレビュー)機能、read-only権限、操作ログ/ロールバックの仕組みが有効です。
  • 必ずダミーデータ・テストデータを利用し、顧客データを直接扱わない運用にする(セキュリティ確保と、失敗を気にせず快適に試せる環境の両面で重要)

パフォーマンス(バッチ処理

  • ノートを一件ずつHTTPで処理すると、件数が増えるにつれ遅延や不安定さが生じます。
  • /bulkエンドポイント、ページング、レート制御といった工夫が必要です。

セキュリティ

今回の仕組みは開発・検証を想定しており、認証やアクセス制御を入れないまま 業務データを扱うのは危険です。

認証トークンの導入、DEBUGビルド限定化、アクセスログ保存などが必須です。

注意点

開発中のデバッグや検証では大幅な効率化が見込めますが、業務で使う場合には「誤操作」「性能」「セキュリティ」の3点に特に注意が必要です。

まとめ

今回実装したMCPサーバーを活用すれば、CursorやAIエージェントを「データベース操作の UI」として利用することができます。この仕組みはSwiftDataに限らず、Core Data / Realm / Firebase / plist など様々なデータベースに応用可能です。

さらに大きなメリットは、データベース固有の不具合調査やデバッグにも役立つ点です。

例えば、

  • 「特定ユーザーのレコードだけが保存されない」ケースで、そのユーザーのデータを自然言語で即座に検索
  • 大量データを一括投入して、アプリのパフォーマンスや同期挙動を検証
  • 実運用に近いシナリオをAIに指示して再現 → そのままログを確認

といった形で、従来ならスクリプトや専用ツールを用意して人間が操作や調査しなければならなかった作業を、自然言語だけで手早く試せるようになります。

「CursorやAIをDBのUIにする」発想は、開発効率の向上だけでなく、トラブルシューティングや品質改善にも直結する新しいアプローチです。ぜひ自分のプロジェクトでも試してみてください。

あとがき

今回はNode.jsを使った例を中心に紹介しましたが、実はSwift向けのMCPサーバー用ライブラリも存在します。これを使えばSwiftだけで完結できるので、iOS/macOSアプリ開発との相性も抜群です。気になった方はぜひこちらの方法も試してみてください

github.com

なお、スパイダープラスでは仲間を募集中です。 スパイダープラスにちょっと興味が出てきたなという方や、もっといい方法を知っているという方がいらっしゃったらお気軽にご連絡ください。