こんにちは、レシピ事業部の山田(@0x746572616e79)です。
AIコーディングの精度向上には、プロンプトを工夫することが有効な時期がありました。最近では、限られたコンテキストに何をどのタイミングで入れるかを設計する「コンテキストエンジニアリング」という考え方が注目されています。私たちのチームでも、長大なガイドラインを読ませるのではなく、AIが必要な時に正確なコンテキストを取得できる仕組み側の整備を進めています。
本記事ではその一例として、Swift Macroの展開結果をCoding Agentに渡すMCPサーバーを紹介します。
テスト用コードをマクロで宣言的に生成する
前提として、私たちのテスト基盤の話から始めます。以前、テスト用のモックは次のような手書きのクラスでした(説明用に簡略化しています)。
final class MockUserService: UserServiceProtocol { private(set) var fetchUserNameArgs: [Int] = [] var fetchUserNameResult: String = "" func fetchUserName(id: Int) -> String { fetchUserNameArgs.append(id) return fetchUserNameResult } }
プロトコルにメソッドが増えるたびにすべてのMockへ似たようなコードを書き足すことになり、退屈な割にミスが混入しやすい作業でした。そこで実装したのが、テスト用のモックコードを自動生成する @AutoMock というSwift Macroです。プロトコルに付与すると、コンパイル時にモッククラスが生成されます。
@AutoMock protocol UserServiceProtocol: AnyObject { func fetchUserName(id: Int) -> String }
これが以下のように展開されます。
class MockUserServiceProtocol: UserServiceProtocol { enum Invocation { case fetchUserName(id: Int) ... } private(set) var invocations: [Invocation] = [] var fetchUserNameResult: String = "" var fetchUserNameStubHandler: ((Int) -> String)? func fetchUserName(id: Int) -> String { invocations.append(.fetchUserName(id: id)) if let stubHandler = fetchUserNameStubHandler { return stubHandler(id) } return fetchUserNameResult } }
呼び出し履歴の記録(invocations)、返り値の差し替え(fetchUserNameResult)などテストに必要な機能一式が揃ったモックが、宣言側の1行で定義されます。
同様に、値型のテストデータ生成には @AutoFake というマクロを使っています。struct/enumに付与すると fake() メソッドが生成されます。
@AutoFake struct User { let name: String let age: Int let email: String } // ↓ 展開結果(抜粋) extension User: AutoFakeable { static func fake( name: String = "", age: Int = 0, email: String = "" ) -> Self { Self( name: name, age: age, email: email ) } }
テストコードでは mock.fetchRecipesResult = [Recipe.fake()] のように組み合わせて使います。開発者にとってもAIにとっても、書き換えが必要なコードの量が大幅に減りました。ちなみに、既存の手書きMockから @AutoMock への移行作業自体もCoding Agentに任せて進めました。
マクロ展開後のコードが見えない
このマクロ群には、AIコーディングと組み合わせたときに固有の問題がありました。Swift Macroはコンパイル時に展開されるため、Coding Agentには展開後のコードが見えません。
Coding Agentがテストを書くには、MockUserServiceProtocol にどんなプロパティが生えているか、Invocation のcase名が何になるかを知る必要があります。最初はプロジェクトで管理しているガイドラインのMarkdownに生成ルールを文章で記述するアプローチを取りました。当時のガイドラインからcase命名規則の一部を抜粋します。
caseの命名規則: - 原則はメソッド名と同じ名前です - protocol名が
InteractorDelegate,InteractorUIDelegate,ViewControllerDelegateというsuffixを持つ場合、protocol名からDelegateまたはUIDelegateを除いた名前をメソッド名から取り去ったものを、camelCase にしたものになります - メソッド名がプロトコル名の短縮形と完全一致する特殊ケース: (中略)sender object を除いた最初のパラメータの外部ラベルが case 名になります - オーバーロードメソッドの場合: 同じ名前のメソッドが複数ある場合、case 名の末尾にすべてのパラメータの外部ラベルを UpperCamelCase で連結したものを付加して区別します
人間が読んでも一度では理解しづらい規則です。マクロの仕様が増えるたびにガイドラインも育ち、最終的に445行に達していました。そしてCoding Agentはこの文章を読んでもしばしば誤認識し、次のようなコンパイルエラーを頻発させていました。
// Agentが生成したコード(誤) #expect(delegate.invocations == [.sampleInteractorDidCancel]) // 実際に展開されるcase名(正) #expect(delegate.invocations == [.didCancel])
エラーを見たCoding Agentがビルドを回して修正を試み、また別の箇所で誤認識する。このループはトークンも時間も浪費します。仕様を文章で説明して推測させるアプローチには限界があります。
マクロを展開するMCPサーバーをつくる
そこで発想を変え、仕様書を読ませて展開結果を推測させるのではなく実際に展開した結果そのものをAgentに渡す方針に切り替え、Swift Macroを展開してその結果を返すMCPサーバー「MacroExpanderMCP」を実装しました。
macroターゲットはimportできない
先ほどのセクションで登場したAutoFake, AutoMock共に役割は異なりますが構成はほとんど同じであるため、説明を一部省略するために以降はAutoMockマクロにフォーカスして話を進めます。
前提として、AutoMockは大きく2つのターゲットで構成されます。@AutoMock のようなマクロ宣言を置くターゲットと、SwiftSyntaxで実際の変換処理を行う実装ターゲットです。前者が AutoMock、後者が AutoMockImplementation という名前です。
MCPサーバーからは、この AutoMockImplementation にある展開ロジックをそのまま再利用したいところですが、実装ターゲットは Package.swift で .macro() として宣言される特殊なターゲットで、コンパイラプラグインとして別プロセスで動く前提のため、通常のターゲットから直接importできません。
とはいえ展開ロジックを二重管理したくはありません。そこで、プラグインのエントリポイント(AutoMockPlugin.swift)を除いた実装ファイルをsymlinkで共有し、通常の .target としてもビルドできるようにしました。
Packages/AutoMock/Sources/
├── AutoMock/ # マクロ宣言
├── AutoMockImplementation/ # .macro() ターゲット(コンパイラプラグイン)
│ ├── AutoMockMacro.swift
│ ├── AutoMockMacroError.swift
│ ├── AutoMockPlugin.swift # プラグインのエントリポイント
│ └── SwiftSyntaxExtensions.swift
└── AutoMockImplementationLib/ # .target(symlinkで実装を共有)
├── AutoMockMacro.swift -> ../AutoMockImplementation/AutoMockMacro.swift
├── AutoMockMacroError.swift -> ../AutoMockImplementation/AutoMockMacroError.swift
└── SwiftSyntaxExtensions.swift -> ../AutoMockImplementation/SwiftSyntaxExtensions.swift
// Package.swift(抜粋) .macro( name: "AutoMockImplementation", dependencies: [...] ), // Library variant of the macro implementation for use outside the compiler plugin context. // Sources are symlinked from AutoMockImplementation (excluding AutoMockPlugin.swift). .target( name: "AutoMockImplementationLib", dependencies: [...], path: "Sources/AutoMockImplementationLib" ),
これによりMCPサーバーからコンパイラプラグインと全く同じ展開ロジックを呼び出せます。
SwiftSyntaxで構文レベルの展開をする
展開処理はSwiftSyntaxが提供する SyntaxProtocol.expand(macros:) をほぼそのまま使っています。
static func expandAutoMock(source: String) throws -> String { let macros: [String: any Macro.Type] = [ "AutoMock": AutoMockMacro.self ] return try expand(source: source, macros: macros) } private static func expand(source: String, macros: [String: any Macro.Type]) throws -> String { let tree = Parser.parse(source: source) let expanded = tree.expand( macros: macros, contextGenerator: { _ in BasicMacroExpansionContext( sourceFiles: [tree: .init(moduleName: "ExpanderModule", fullFilePath: "input.swift")] ) }, indentationWidth: .spaces(4) ) return expanded.description }
単なる構文レベルの展開で型チェックは行われないため、渡すソースコードは依存する型が解決はしていません。プロトコル宣言のスニペットを文字列で渡すだけで展開結果が返ってきます。
そのためこれは、AutoMockやAutoFakeのように付与された宣言の構文だけで出力が決まるマクロでのみ成立するアプローチです。Swift Macroは展開時に MacroExpansionContext を通じてソース位置(location(of:))や外側を囲む宣言(lexicalContext)、一意な名前の生成(makeUniqueName(_:))も参照できます。これらに依存するマクロでは、ダミーのファイル名を与えたスニペット単体の展開結果が実際のコンパイル結果と一致する保証はありません。私たちのマクロは付与された宣言の構文だけを入力とするため、そのままコンパイラの外へ持ち出すことができました。
あとはこの展開処理をMCPのツールとして公開します。JSON-RPC 2.0 over stdioを話す小さなMCPサーバーで、標準入力を1行ずつ読んでJSONをパースし、initialize / tools/list / tools/call の3メソッドに応答するだけです。
struct MCPServer { func run() { while let line = readLine(strippingNewline: true) { // JSONをパースして initialize / tools/list / tools/call に応答する ... } } }
expand_automock と expand_autofake の2つをツールとして公開し、リポジトリ直下の .mcp.json に登録しています。swift run で起動するので、チームメンバーは特別なセットアップなしにそのまま使えます。
{ "mcpServers": { "macro-expander": { "command": "swift", "args": ["run", "--package-path", "Packages/MacroExpanderMCP", "MacroExpanderMCP"] } } }
導入後は、Coding Agentがテストを書く前に expand_automock ツールを呼び出すようになります。
// MCPサーバーへの入力 expand_automock(source: """ @AutoMock protocol SampleInteractorDelegate: AnyObject { func sampleInteractorDidCancel(_ interactor: SampleInteractor) } """) // MCPサーバーからの出力(展開結果) class MockSampleInteractorDelegate: SampleInteractorDelegate { enum Invocation { case didCancel // ← プレフィックスが省略されていることがわかる ... } private(set) var invocations: [Invocation] = [] func sampleInteractorDidCancel(_ interactor: SampleInteractor) { invocations.append(.didCancel) } }
先ほどの複雑な命名規則を文章で理解する必要はなくなり、「sampleInteractorDidCancel のcase名は didCancel になる」という事実が、展開結果として直接コンテキストに入ります。
ガイドライン側に残した指示はシンプルです。
生成コードの確認には MCP ツール
mcp__macro-expander__expand_automockを使うこと。case 命名規則、associated value 規則、デフォルト値、Observer 関数判定といった詳細はそちらで実際の出力を確認できます。
まとめ
- Coding Agentのためのコードベース整備の一例として、Swift Macroを展開して結果を返すMCPサーバーを紹介しました。
- Coding AgentがSwiftMacroの細かい考慮事項を見逃す回数が減り、ビルドエラーをループする問題が減りました。また、仕様変更によるガイドラインの保守が不要になり445行あったAutoMockのガイドラインは、規約と典型的なAPIの紹介だけを残して77行になりました。
- 一方で、このアプローチは付与された宣言の構文だけで出力が決まるマクロだから成立している点には注意が必要で、ソース位置や外側の宣言など展開コンテキストに依存するマクロでは、同じ手はそのまま使えません。
お知らせ
クックパッドは、2026年9月11日(金)〜9月13日(日)に有明セントラルタワーホール&カンファレンスおよびオンラインで開催される iOSDC Japan 2026 に、今年もゴールドスポンサーとして協賛しています。
今年はスポンサーブースの出展はありませんが、クックパッドのエンジニアも参加者として会場に足を運ぶ予定です。一緒に楽しみましょう!










