壁紙アプリの新しい書き出しを WebP でまとめ、命名規則の揺れを拾ってもらうつもりでエージェントに添付しました。返ってきたのは沈黙です。エラーも出ず、添付そのものが無かったことになっていました。
同じ週に、癒し系アプリの効果音を .opus で渡したときは、扱えない形式だという返答が来ました。ファイルはどちらも手元のプレイヤーで問題なく開けます。
症状は違いますが、原因の層は同じでした。ファイルの中身ではなく、送り出す側がそのファイルに何の MIME を貼ったか、です。
8月20日のハブ 2.9.1 で WebP の添付が加わり、同じ日の CLI 1.1.17 では .ogg / .opus / .ogv が汎用の Ogg として送られてモデルに拒否される問題が修正されました。直ったこと自体より、なぜそうなっていたのかを掴んでおくほうが、次に別の拡張子で詰まったときに効きます。
拒否は3つの層のどこかで起きています
添付が届かないと分かった時点で、いきなりファイルを変換し直すのは遠回りです。まず、どの層で落ちているかを決めます。
| 層 | 表に出る症状 | 最初に見るもの |
|---|---|---|
| ①クライアントと版 | 添付しても無反応。会話に何も残らない | ハブ / IDE / CLI の版と、その版で対応した形式 |
| ②MIME の決定 | 添付は通るが、モデルの側が扱えないと返す | 手元での判定結果(下記の2通り) |
| ③受け渡しの経路 | 会話が重くなる、参照だけが残る | 直接添付か、MCP サーバーの戻り値か |
私がつまずいた2件は、それぞれ①と②でした。WebP は 2.9.1 より前のハブでは受け口そのものが無く、Opus は受け口はあるのに貼られた MIME が粗すぎたわけです。Windows で画像やメディアの添付が失敗していた問題も①の側で、IDE 2.5.5(8月13日)で直っています。
①は版を上げれば終わります。厄介なのは②で、こちらは版を上げても、環境によっては再発します。理由は次の節で測ります。
手元の判定を2通り並べると、食い違いが見えます
MIME の決め方には、中身の先頭バイトを見る方法と、拡張子の対応表を引く方法の2通りがあります。同じファイルに両方を当てて、結果を並べてみました。
# 中身から判定する(マジックバイト)
for f in sample.png sample.webp sample.ogg sample.opus sample.ogv; do
printf '%-13s %s\n' "$f" "$(file -b --mime-type "$f")"
doneUbuntu 22.04・file 5.41 での出力です。
sample.png image/png
sample.webp image/webp
sample.ogg audio/ogg
sample.opus audio/ogg
sample.ogv audio/ogg
次に、拡張子の表を引く側です。
import mimetypes
for name in ["sample.png", "sample.webp", "sample.ogg", "sample.opus", "sample.ogv"]:
print(f"{name:<13} {mimetypes.guess_type(name)[0]}")Python 3.10.12 での出力です。
sample.png image/png
sample.webp None
sample.ogg audio/ogg
sample.opus audio/ogg
sample.ogv video/ogg
並べると、二重の穴が見えてきます。
| ファイル | 中身から | 拡張子から | 起きること |
|---|---|---|---|
sample.webp | image/webp | (決まらない) | 表を引く実装では型が付かず、汎用のバイト列として送られる |
sample.opus | audio/ogg | audio/ogg | 両方一致するが、Opus であることが落ちている |
sample.ogv | audio/ogg | video/ogg | 音声と映像で判定が割れる |
.ogg / .opus / .ogv の3つは、先頭4バイトがいずれも 4f676753(OggS)でした。同じコンテナに包まれている以上、中身を覗く方法では区別のしようがありません。中身から判定する実装が Ogg 系をまとめて粗い型で送っていた、という挙動には、こういう素直な理由があります。
WebP のほうは逆で、拡張子の表に行が無いために型が決まりません。決まらないものを送る側は、たいてい汎用のバイト列として扱います。受け取ったモデルからすれば、画像として渡されていないわけですから、見ないという判断は筋が通っています。
Opus が汎用の Ogg になるのは、機械側の表が勝つからです
ここがいちばん引っかかった点でした。Python の内蔵テーブルは、.opus に対して audio/opus を持っています。ところが初期化を通すと、値が入れ替わります。
import mimetypes
print(mimetypes.types_map.get(".opus")) # 初期化前: audio/opus
mimetypes.init()
print(mimetypes.types_map.get(".opus")) # 初期化後: audio/ogg上書きしているのは、その機械に置かれている対応表です。手元の /etc/mime.types を見ると、こう書かれていました。
audio/ogg oga ogg opus spx
opus が audio/ogg の行に同居しています。内蔵の値より、機械に置かれた表のほうが後から読まれる分だけ強い、という順序です。
この構造が意味するのは、同じコードでも機械が変われば結果が変わる、ということです。手元では通っていた添付が、対応表を持たない小さなコンテナや CI の中では別の型になる。個人開発で作業機とビルド環境を行き来していると、この差はまとまった時間を溶かします。私自身、最初は書き出し設定のほうを何度も疑いました。
MCP 経由で来るファイルは、判定する主体が違います
三つ目の層です。エージェントにファイルが渡る経路は、人が添付するものだけではありません。MCP サーバーが画像や PDF を返してくることもあります。
この経路では、型を決めるのは手元のクライアントではなく、サーバー側の宣言です。手元でいくら判定を揃えても届きません。切り分けの順序としては、まず「そのファイルは自分が添付したのか、ツールが返したのか」を確かめる必要があります。
ハブ 2.9.1 では、接続済みの MCP ツールが返す大きなバイナリは会話へ展開されず、ファイルへ保存されて参照される形になりました。以前は画像や PDF が会話の中で展開され、そのターンごと壊れることがあった部分です。会話に本文が見えず参照だけが残っている場合、それは不具合ではなく、この保存方式が働いた結果です。
MCP サーバーの起動段階で落ちている場合は、型の話に入る前の問題です。そちらはMCP サーバーが1本も起動しない原因は、起動前の 40 行で特定できますでまとめています。
添付する前に通しておく短い確認
②の層は、渡す前に手元で潰せます。2通りの判定を突き合わせ、食い違いと欠落を先に出すだけの短いスクリプトです。
#!/usr/bin/env python3
"""添付予定のファイルについて、中身と拡張子それぞれの MIME 判定を突き合わせる。"""
import mimetypes
import subprocess
import sys
# 中身からは区別できず、判定が揃っていても粗いままになる拡張子
COARSE = {".opus": "audio/opus", ".ogv": "video/ogg", ".oga": "audio/ogg"}
def by_content(path):
try:
r = subprocess.run(["file", "-b", "--mime-type", path],
capture_output=True, text=True, check=True)
return r.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def main(paths):
flagged = 0
for p in paths:
ext = "." + p.rsplit(".", 1)[-1].lower() if "." in p else ""
by_ext = mimetypes.guess_type(p)[0]
by_con = by_content(p)
if by_ext is None:
print(f"[表に無い] {p}: 拡張子から決まりません(中身は {by_con})")
flagged += 1
elif by_con and by_ext != by_con:
print(f"[食い違い] {p}: 拡張子 {by_ext} / 中身 {by_con}")
flagged += 1
elif ext in COARSE and by_ext != COARSE[ext]:
print(f"[粗い] {p}: {by_ext} と出ますが、本来は {COARSE[ext]} です")
flagged += 1
else:
print(f"[一致] {p}: {by_ext}")
print(f"--- 要確認 {flagged} 件 / 全 {len(paths)} 件 ---")
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))先ほどの5ファイルに通した結果です。
[一致] sample.png: image/png
[表に無い] sample.webp: 拡張子から決まりません(中身は image/webp)
[一致] sample.ogg: audio/ogg
[粗い] sample.opus: audio/ogg と出ますが、本来は audio/opus です
[食い違い] sample.ogv: 拡張子 video/ogg / 中身 audio/ogg
--- 要確認 3 件 / 全 5 件 ---
COARSE の3行を足したのは、判定が一致していることと、その値が正しいことが別だと分かったからです。.opus は両方が audio/ogg で揃います。揃っているのに、Opus であるという情報は落ちています。一致だけを合格条件にすると、この一件を素通りさせてしまいます。
終了コードを非ゼロで返しているのは、添付を伴う自動処理の手前に置けるようにするためです。App Store 提出前のアセット確認のように、まとめて機械へ渡す工程では、通す前に止まってくれるほうが助かります。
次に確かめる一手
手元で添付する予定のファイルを、まずこのスクリプトに通してみてください。[表に無い] が出た拡張子だけが、対応表を持たない環境で型を失う候補です。そこだけを、クライアント側で明示的に型を指定するか、対応の確かな形式で書き出し直すかに振り分ければ済みます。
版を上げること自体は、いつでもできます。ただ新しい版は順に配られるため、手元へ届くまで数日かかることもあります。届くまでの間、どの層で落ちているかを判別できる状態にしておくと、待っている時間が観察の時間に変わります。
複数の機械を行き来する構成そのものをどう決めるかは、Remote Control をホスト機に入れる判断は、daemon の生存条件と遮断経路で決まりますで、資格情報の置き場所と併せて掘り下げました。今回の話は、その構成の上を実際に流れるファイルの側です。
小さな引っかかりでしたが、掴んでみると同じ形の問題を先回りできるようになりました。お読みいただきありがとうございました。