---
title: "MCPのレスポンスをMarkdownからTOONに変えたら、ツールの説明文まで書き直すことになった"
description: "Vigilare・Chimr・PortusのMCPサーバーで、ツールのレスポンスをMarkdownからTOONへ変えました。nilをnullとして書かないと表形式が崩れること、Markdownの表を前提に書いたツールの説明文を直すことになったこと、Markdownを残したVigilareでは同じ修正を2箇所に入れ続けていることを書きます。"
category: "AI"
tags: ["MCP","TOON","Swift","Vigilare","Chimr","Portus","macOS"]
publishedAt: "2026-07-13"
lastmod: "2026-07-13"
---

MCPサーバーを内蔵している自社のmacOSアプリで、ツールのレスポンス形式をMarkdownから [TOON](https://github.com/toon-format/spec) へ変えました。中身は同じで、書き方だけが変わっています。

- [Vigilare](https://vigilare.labee.dev/) — リマインダーを扱うタスク管理アプリ
- [Chimr](https://chimr.labee.dev/) — カレンダーの予定をメニューバーから見るアプリ
- [Portus](https://labee.jp/products/portus) — App Store Connectでのリリース作業をMacから行うアプリ

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

```markdown
# 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
```

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

```text
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件の予定を書き出していたのはこのコードです。

```swift
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のプロパティに移し、記号はエンコーダーに任せることができますが、以下のようなステータスの表記は悩みました。

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

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

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

DTOのプロパティに入れるときに、`🔴 Ongoing`は`ongoing`にしました。`⚠️ Starting soon`は`starting_soon`、`✓ Completed`は`completed`です。

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

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

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

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

## nilを落とすと表が崩れる

[toon-swift](https://github.com/toon-format/toon-swift) は`nil`のOptionalをキーごと省略せず、`null`と書きます。

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

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

```swift
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`にします。

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

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

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

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

```text
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は、もっと崩れやすい形をしています。`overdue`と`hasNotes`を、真のときだけ値を入れて偽なら`nil`にしているからです。

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

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

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

```swift
// 移行前は空なら "—" を返していた
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つになっている](/images/posts/mcp-markdown-to-toon/three-decisions.svg)

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

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

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

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

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

::card[https://vigilare.labee.dev/]

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

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

::card[https://chimr.labee.dev/]

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

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

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

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

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

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

```swift
} 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だけは、この続きが書けました。

```text
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`には、こう書いていました。

```text
... 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.
```

`row`、`columns`、`Rows`。どれもMarkdownの表を前提にした語です。

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

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

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

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

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

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

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

```text
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の文言を見ていたからです。

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

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

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

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

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

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

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

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

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

::card[/posts/mcp-daemon-tcc-disclaim]

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

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