AI

MCPのレスポンスをMarkdownからTOONに変えたら、ツールの説明文まで書き直すことになった

  • MCP
  • TOON
  • Swift
  • Vigilare
  • Chimr
  • Portus
  • macOS

MCPサーバーを内蔵している自社のmacOSアプリで、ツールのレスポンス形式をMarkdownから TOON へ変えました。中身は同じで、書き方だけが変わっています。

  • Vigilare — リマインダーを扱うタスク管理アプリ
  • Chimr — カレンダーの予定をメニューバーから見るアプリ
  • Portus — App Store Connectでのリリース作業をMacから行うアプリ

移行前のChimrは、今日の予定を聞かれるとこう返していました。

# Events for 2026-07-13

Total: 2

- **Sprint planning** 🔴 Ongoing
  - ID: `E1`
  - Time: 10:00 - 11:00
  - Calendar: Work
  - Video: Google Meet
  - Attendees: 3

- **1on1**
  - ID: `E2`
  - Time: 15:00 - 15:30
  - Calendar: Work
  - Location: Room A
  - Attendees: 2

今は同じ予定がこう返ります。

date: 2026-07-13
total: 2
events[2]{id,title,status,time,calendar,location,video,attendees}:
  E1,Sprint planning,ongoing,"10:00 - 11:00",Work,null,Google Meet,3
  E2,1on1,null,"15:00 - 15:30",Work,Room A,null,2

書き換えたのはフォーマッターだけのはずでした。実際には、DTOの作り方、エラーの返し方、ツールの説明文、テストの期待値まで手を入れることになりました。

Markdownを文字列で組み立てて返していた

移行前は、どのサーバーもStringを連結してMarkdownを作っていました。Chimrで1件の予定を書き出していたのはこのコードです。

static func formatEventSummary(_ event: Event, privacyLevel: MCPPrivacyLevel) -> String {
  var output = ""

  let title = privacyLevel == .strict ? "[Event]" : event.title
  let timeRange = formatTimeRange(event)
  let status = getEventStatus(event)

  output += "- **\(title)** \(status)\n"
  output += "  - ID: `\(event.id)`\n"
  output += "  - Time: \(timeRange)\n"

  if privacyLevel != .strict {
    output += "  - Calendar: \(event.calendarTitle)\n"
    // 以下、場所・ビデオ会議・参加者数を同じ形で足していく

この関数には、性質の違うものが同居していました。タイトルや時刻といった値と、ハイフンやアスタリスクやバッククォートやインデントといった記号です。

値はEncodableなDTOのプロパティに移し、記号はエンコーダーに任せることができますが、以下のようなステータスの表記は悩みました。

// 移行前
} else if now >= event.startDate && now <= event.endDate {
  return "🔴 Ongoing"
// 移行後
} else if now >= event.startDate && now <= event.endDate {
  return "ongoing"

絵文字と大文字は、値ではなく、人間が見分けやすいようにするための装飾でした。文字列を手作業で組み立てている間は、値と装飾が同じ行に並ぶので境目が見えません。

DTOのプロパティに入れるときに、🔴 Ongoingongoingにしました。⚠️ Starting soonstarting_soon✓ Completedcompletedです。

また、Portusのアプリ一覧では、値でも装飾でもないものが入っていました。空欄の埋め方です。

guard !versions.isEmpty else {
  lines.append("\(prefix) — | — | — | — |")
  continue
}

バージョンが1つも無いとき、セルをダッシュで埋めていました。表の形を保つためだけの文字です。

移行前のMarkdown文字列が抱えていたもののうち、アプリ名やロケールなどの値はDTOのプロパティへ、表の列見出しと区切り記号はTOONエンコーダーへ、空のセルを埋めていたダッシュはエンコーダーが書くnullへ移り、いずれもレスポンスの中に残る。データが1件も無いときに次の操作を伝えていた一文だけはレスポンスの中に置き場所が無く、レスポンスには現れずツール一覧に載るツールの説明文へ移った

nilを落とすと表が崩れる

toon-swiftnilのOptionalをキーごと省略せず、nullと書きます。

Swiftが自動生成するエンコード処理は、OptionalにencodeIfPresentを呼びます。標準の実装はnilなら何も書かずに戻るので、放っておけばキーが落ちます。

toon-swiftはこれを型ごとに上書きして、nilencodeNilへ回しています。

func encodeIfPresent(_ value: String?, forKey key: Key) throws {
    guard let value = value else {
        try encodeNil(forKey: key)
        return
    }
    try encode(value, forKey: key)
}

encodeNilはキーを記録したうえで、値を.nullにします。

func encodeNil(forKey key: Key) throws {
    trackKey(key.stringValue)
    container[key.stringValue] = .null
}

表形式が保てるのは、この挙動のおかげです。エンコーダーが表形式を選ぶのは、配列の要素が全てオブジェクトで、キーの集合が一致していて、値がどれもスカラーのときです。nullもスカラーとして扱われます。

冒頭のChimrのレスポンスは、locationvideostatusが予定によって欠けていますが、それでも1行1件の表に収まっています。

キーを落とす実装にしたらどうなるか、同じデータでエンコードして比べました。

date: 2026-07-13
total: 2
events[2]:
  - id: E1
    title: Sprint planning
    status: ongoing
    time: "10:00 - 11:00"
    calendar: Work
    video: Google Meet
    attendees: 3
  - id: E2
    title: 1on1
    time: "15:00 - 15:30"
    calendar: Work
    location: Room A
    attendees: 2

1件目にlocationが無く2件目にstatusが無い、それだけで配列全体が表になれず、1件ずつの箇条書きに戻ります。ヘッダーで1回書けば済んだキー名が、件数分だけ繰り返されます。

Vigilareのタスク一覧DTOは、もっと崩れやすい形をしています。overduehasNotesを、真のときだけ値を入れて偽ならnilにしているからです。

overdue: isOverdue ? true : nil,
...
hasNotes: hasNotes ? true : nil,

キーを落とす実装だったら、期限切れのタスクとそうでないタスクが1件ずつ混ざるだけで表が崩れます。

nullが入るようになって、得たものもあります。Portusでは、値が空のときダッシュを置いていたのをやめて、nilを返すようにしました。

// 移行前は空なら "—" を返していた
private static func preview(_ value: String?) -> String? {
  guard let value, !value.isEmpty else { return nil }

移行前は、値が未設定でも空でも、レスポンスの上ではでした。ユーザーが本当にダッシュを書いていた場合も同じです。

いまは未設定と空がnullになるので、値としてのダッシュと取り違えることがありません。

TOONにすれば何でも表形式になるわけではありません。Chimrの期間指定のレスポンスは日付ごとのグループが並ぶ構造で、要素が配列を持つので表になりません。Portusのアプリ一覧も、アプリがバージョンの配列を持つので同じです。

VigilareだけはレスポンスをMarkdownに戻せるように

ChimrとPortusは切り替えを作らず、Markdownの実装を消しました。Vigilareだけ両方を残しています。

VigilareだけがTOONを既定にしたうえでMarkdownを残し、設定で戻せるようにしていて、フォーマッターは2つ並んだまま残る。メモとコメントに文章が入り、どちらが読みやすいかを決められなかったため。Portusは販売用の文章を扱うが60文字までしか返さず、Chimrはそもそも予定に文章が入らないので、どちらも切り替えを作らずフォーマッターは1つになっている

残すかどうかは、Markdownで書く意味があるかどうかで決めました。Markdownは文章に構造を付ける形式なので、文章が入らなければ何も効きません。

Vigilareの詳細を返すDTOはnotes: String?を持ち、コメントは1件ずつ本文を持ちます。

private struct CommentDTO: Encodable {
  let date: String
  let content: String
}

contentに入るのはユーザーが書いた文章で、長さを切らずに返します。見出しやリストがそのまま効きます。

TOONの表に並べたものとMarkdownのまま返したもので、どちらが読みやすいかを決められませんでした。それで戻せるようにしています。

VigilareFloating Reminders for macOSKeep your Apple Reminders always on top with a floating window for macOS. Works in fullscreen apps, with quick actions, list filtering, and a built-in Markdown editor.

Chimrがツールで返す予定に、文章は入りません。IDとタイトル、状態、時間帯、カレンダー名、場所、ビデオ会議、参加者数です。参加者は名前の一覧ではなく人数で、Int?が入ります。

メモを載せる分岐はformatEventFullのほうに書きました。ただしこのメソッドを呼ぶツールを作っていないので、メモが外へ出ることはありません。

ChimrMeeting Reminder for macOS with built-in MCPA macOS menu bar meeting reminder that counts down to your next event, chimes a minute before, and joins Zoom, Meet, or Teams in one click. Built-in MCP server lets Claude read your day.

Portusは事情が違います。扱っているのはApp Store Connectのメタデータなので、descriptionwhatsNewpromotionalTextも、それ自体が販売用の文章です。Chimrと同じ理由は使えません。

切り替えを作らずに済んだのは、その文章を全文では返していないからです。値は全てpreview()を通していて、60文字を超えたところで切り詰めます。エージェントが受け取るのは冒頭だけなので、マークアップを効かせる意味がありません。

エンコードが失敗するようになった

文字列を自分で連結している間は、どう並べてもエラーになりませんでした。エンコーダーは失敗を返してきます。

どのMCPサーバーにもoutputEncodingFailedを足しました。フォーマッターの公開メソッドはthrowsになり、呼び出し側にはtryが要ります。Chimrはハンドラーがもともとasync throwsだったので、書き足したのはtryだけで済みました。

迷ったのは、この失敗をどう返すかです。プロトコルレベルのエラーにはせず、isErrorを立てた通常のツール結果として返しています。3つのMCPサーバーで共有しているライブラリに、この変換を置きました。

} catch let error as any MCPUserFacingError {
  return .init(
    content: [.text(text: error.guidanceText, annotations: nil, _meta: nil)],
    isError: true)
}

MCPのSwift SDKはMCPErrorでない例外をすべてMCPError.internalErrorに潰します。JSON-RPCのプロトコルエラーになるので、クライアントには文面の無い「Tool execution failed」しか出ません。文面を見せたいエラーは、プロトコルエラーにしてはいけません。

エラーの文面はサーバーごとに変えました。Chimrは失敗の理由をそのまま返し、Portusは「Please retry.」で終わります。Vigilareだけは、この続きが書けました。

Failed to encode the tool response: <detail>. Switch the MCP output
format back to Markdown in Vigilare's settings and restart the
background service as a workaround.

戻す先を残したので、失敗したときに逃げ道を案内できます。Markdownを消したほうでは、これができません。

ツールの説明文がMarkdownの表を前提にしていた

Portusの移行では、レスポンスを組み立てるコードの外にも手を入れました。ツールの説明文です。

移行前のportus_get_metadataには、こう書いていました。

... one row per (locale, field) with the pulled (ASC) value, the local
draft, and whether it's dirty as columns. Rows where both pulled and
draft are empty are hidden by default.

rowcolumnsRows。どれもMarkdownの表を前提にした語です。

TOONにしても1行1件の形は残ります。ただし呼び方が違って、行はエントリ、列はフィールドです。one row perone entry perRows whereEntries whereに直しました。portus_list_appsのほうではa single columna single fieldにしています。

as columnsだけは言い換えではなく句ごと消しました。TOONのヘッダーがフィールド名を並べるので、わざわざ「列として」と書く必要がありません。

返すものの書き方を変えたら、それを説明していた文も直すことになりました。

説明文には、もう1つ移したものがあります。移行前のPortusは、キャッシュが空のときに、次に何をすればいいかをレスポンスそのもので伝えていました。

return "No apps cached locally yet. Pull an app from Portus's UI first."

TOONでは空でも同じDTOを返す方針にしたので、レスポンスはapps[0]:の1行になります。この一文を置く場所がありません。

かといって捨てられません。これを読まないと、エージェントはPullが必要なことに気づけないからです。移した先はportus_list_appsの説明文でした。

If this returns no apps, none have been Pulled yet — open Portus's UI,
add the app, and Pull it once; there is no MCP tool to do this.

説明文はツール一覧に載るので、エージェントは呼ぶ前に読みます。空が返ってから案内を読むのではなく、空が何を意味するかを先に知った状態で呼べます。

移す先を用意できたのはこれだけです。portus_get_metadataの「まずPullを実行しろ」と、portus_get_diffの「未Pushの変更は無い」は、どこにも移さないまま消しています。

項目を足すのが楽になった

移行の直後に、Portusのアプリ一覧へissuer IDを足しました。

Markdownの表だったころなら、列見出しの行と、区切り記号の行と、セルを組み立てている全ての行を直すことになります。DTOになったあとは、値型とDTOにプロパティを足して、値を入れているところに1行足すだけで済みました。

表の形を手作業で保っていたときは、直す行の数が列の数に比例していました。エンコーダーに任せると、列がいくつあっても変わりません。

期待値がフォーマッターの外に残っていた

Chimrは1つのコミットでフォーマッターを移しました。そのときフォーマッターのテストは全部書き換えています。

そのあと、もう1つコミットが要りました。ハンドラーのテストが、旧Markdownの文言を見ていたからです。

-      XCTAssertTrue(text.contains("No events found"))
+      XCTAssertTrue(text.contains("total: 0"))

"No events found"は移行前のformatEventsが空のときに返していた文字列です。フォーマッターを丸ごと差し替えたのに、その文字列をcontainsで見ているアサーションをハンドラー側に残したままにしていて、気づくのが遅れました。

レスポンスの形式を変えるとき、期待値がフォーマッターのテストの中だけに閉じているとは限りません。

Markdownを残した分の負担が今も続いている

VigilareにはMCPFormattersMCPToonFormattersが並んだままです。移行が終わったあとも、Markdown側を放っておくことはできません。

TOONを全ツールへ広げたコミットで、どちらの形式にも足りていなかったデータを両方へ足しました。タスクを完了させたときに完了日時を返していなかった件と、一覧の各項目に作成日時が無かった件です。Markdown側は、文字列を組み立てる行がそのまま増えます。

if let creationDate = reminder.creationDate {
  output += "  - Created: \(formatISODateTime(creationDate))\n"
}

片方だけ直すと、設定をMarkdownに戻したエージェントだけが古いレスポンスを受け取ります。落ちるわけではないので、気づく手がかりもありません。

設定の反映にも制限があります。レスポンスの形式はバックグラウンドサービスが起動したときに一度だけ読んで、以後は握ったままにしています。設定画面で切り替えても、サービスを再起動するまでレスポンスは変わりません。設定画面にもそう書いています。

ブログターミナルにリマインダー権限を渡さずにMCPからEventKitを触るClaude Desktop経由でMCPサーバーを起動するとReminders権限が通らない問題を、独立したLaunchAgentのdaemonへEventKitアクセスを集約して解決しました。結果として、ターミナルやClaude Desktopに権限を与えず、アプリ本体だけに絞れる構成になっています。

ツール呼び出しのパラメーターではなくサービス単位の設定なので、「このレスポンスだけMarkdownで」という指定もできません。どのツールの入力スキーマにも、形式を選ぶプロパティは置いていません。

Markdownへ戻す設定は、いまも残したままです。判断がついたらフォーマッターは1つに減らしますが、そのための材料はまだ出ていません。