筆者が作った whisper-mcp のツールは5つある。うち3つ(transcribe_start / transcribe_status / transcribe_result)は、1回の文字起こしを3回に分けて呼ぶためだけに存在する。1つのツールで「音声を渡したら文字が返る」ほうが素直に見える。それでも分けたのには理由があって、そこがローカルの重い処理をMCPサーバーにするときの最初の分かれ道だった。この記事では、実際に動いている2本のMCPサーバーの設計と、公開スキーマで踏んだ失敗をそのまま書く。
目次
この記事で作るもの
題材は、筆者が実際に作って今も Claude Code につないで使っている2本だ。どちらも独立したリポジトリで、stdio トランスポートのローカル MCP サーバーになっている。
| サーバー | 中身 | 規模 |
|---|---|---|
whisper-mcp |
faster-whisper による音声/動画の文字起こし | 7モジュール / テスト43本 |
scraper-mcp |
X検索・YouTubeチャット収集(既存ツールを呼ぶだけ) | テスト34本 |
どちらも 2026-07-19 に完成させた。mypy と ruff も緑にしてある。この記事は前者を主役にして、設計の判断が分かれた箇所で後者を引き合いに出す形で進める。
最初の判断:ツールを1回で完結させない
MCP のツール呼び出しは、値を返すまで戻れない。文字起こしは音源の長さとモデルサイズ次第で分単位になる。large-v3 は数GBあって、初回は読み込みだけでも待つ。これを1つのツールに詰めると、処理が終わる前にタイムアウトへ突っ込む。
だから ジョブ方式 にした。開始のツールは受け付けた証拠として job_id だけを即座に返し、本体はバックグラウンドで走らせる。呼び出し側は状態を聞きに行き、終わっていたら結果を取りに行く。
ジョブの入れ物は素朴でいい。筆者はメモリ上の辞書とロックだけで始めた。
class InMemoryJobRegistry:
def __init__(self) -> None:
self._jobs: dict[str, Job] = {}
self._lock = threading.Lock()
def create(self, job: Job) -> None:
with self._lock:
self._jobs[job.id] = job
def get(self, job_id: str) -> Job | None:
with self._lock:
return self._jobs.get(job_id)
実行は ThreadPoolExecutor に投げる。並列数は環境変数で決められるようにして、既定は1にした。GPUを1枚しか積んでいないのに複数ジョブを同時に走らせても、取り合いになって遅くなるだけだから。
JobRegistry を Protocol で定義しておいた点は、後から効いた。永続化を SQLite に差し替えるときに、実装を足すだけで済む形になっている(それ自体は v2 送りにした)。
フォルダを渡せるようにする
使い始めてすぐ欲しくなったのが、フォルダ丸ごとの指定だった。1ファイルずつ job_id を管理するのは面倒くさい。入り口で展開してしまえば、あとは同じ流れに乗る。
def expand_paths(paths: list[str]) -> list[str]:
out: list[str] = []
for p in paths:
if os.path.isdir(p):
for name in sorted(os.listdir(p)):
full = os.path.join(p, name)
if os.path.isfile(full) and media.is_supported(full):
out.append(full)
else:
out.append(p)
return out
対応拡張子だけを拾うので、フォルダに画像やテキストが混ざっていても素通りする。バッチ処理では1件が失敗しても残りを続けるようにした。10件投げて3件目で落ちて全部やり直しになる設計は、GPU時間をドブに捨てる。
モジュールの割り方
src/whisper_mcp/ は7つに分けた。役割が混ざると、テストのときに毎回モデルを読み込む羽目になる。
| モジュール | 役割 |
|---|---|
config |
環境変数の読み取り、キャッシュ先、デバイス自動判定 |
models |
Segment / TranscriptionResult / Job のデータ型 |
formats |
txt / srt / vtt / json への整形(純関数) |
engine |
faster-whisper の遅延ロードと呼び出し |
media |
動画から ffmpeg で音声を抜く |
jobs |
バックグラウンド実行、バッチ継続、中止 |
server |
FastMCP と5つのツール定義 |
formats を純関数にしたおかげで、字幕の時刻整形はモデル無しでテストできる。engine の遅延ロードも同じ理由で、インポートしただけで数GBを読みに行かない。
デバイスは起動時に自動で決める。CUDA が使えるなら cuda と float16、無ければ cpu と int8 に落とす。エンジンに faster-whisper を選んだのは、openai-whisper と同じモデルを使いながらGPUで約4倍速く、大きいモデルを常用できるからだ。
つまずいた話①:公開スキーマが嘘をついていた
ここが一番効いた失敗だ。
transcribe_start の引数に型注釈を書いていなかった。FastMCP は型注釈からスキーマを組み立てるので、注釈が無いと全部の引数を string と推論して、そのまま公開する。paths はリストを受け取る設計なのに、外から見えるスキーマは「文字列を1つ」と言っていた。
やっかいなのは、実行時は素通りするところだ。注釈が無い引数は Any 扱いなので、リストを渡せば普通に動く。筆者は自分でリストを渡して動作を確かめていたから、壊れていることに気づけなかった。
困るのはスキーマを信じる側だ。ツールの説明を読んで呼び出しを組み立てるクライアント(LLMを含む)は、「文字列1つしか渡せない」と読む。複数ファイルを指定する手段が、外からは存在しないことになっていた。
# 直す前:注釈なし。FastMCP は全部 string と推論して公開する
def transcribe_start(paths, model_size, language, formats):
...
# 直した後:スキーマが実態と一致する
def transcribe_start(
paths: list[str] | str,
model_size: str | None = None,
language: str | None = None,
formats: list[str] | None = None,
) -> dict:
...
裸の文字列を渡されても吸収するようにしたうえで、スキーマの回帰テストを足した。そのテストは、わざと注釈を消して落ちることを確かめてから採用している。テストが本当に検出できるかは、壊してみないと分からない。
同じ間違いを scraper-mcp でもやっていた。あちらは limit が string で公開されていた。1本目なら見落としで片付けたと思う。2本続けて踏んだ時点で、手順の穴だと認めるしかなかった。
ついでに見つかったのが、transcribe_result がエラーを握り潰す経路だ。「文字起こしは成功したが、ファイルの書き出しに失敗した」場合に、結果が無いことだけを見て分岐していた。形式指定がまともに動くようになって、初めて到達できるようになった経路だった。
つまずいた話②:VADは無条件の改善ではない
音楽や歓声が入る音源で、30秒まるごとが同じ単語の繰り返しで埋まることがある。faster-whisper の処理ウィンドウが丸ごと反復ループに落ちる現象で、その区間の発話は消える。srt を開いてちょうど 30.000 秒を占めるセグメントがあったら、まずこれを疑っていい。
対策として VAD フィルタ(無音区間の除去)を入れた。入れたうえで、既定は無効のままにしてある。
実音源で比べたからだ。お笑いのネタ音源3分半を large-v3 で、VADあり・なしの両方にかけた。消えていた30秒は確かに戻ってくる。ところが他の区間の精度が落ちて、全体の正解率はVADなしのほうが高かった。
そういうわけで「区間が丸ごと消えていると分かっている音源にだけ有効化する」運用にした。この切り替えを今は環境変数でしか持っていないのが心残りで、ジョブ単位で指定できるようにするのが次の宿題になっている。
幻聴対策のパラメータを移植したときには、名前で引っかかった。faster-whisper では log_prob_threshold で、openai-whisper 時代の logprob_threshold ではない。黙って無視されるので、渡したつもりで効いていない状態になる。
テストの緑を信じない
whisper-mcp のテストは43本ある。全部緑で、mypy も ruff も通っている。それでも上の2つの欠陥は緑をすり抜けて残っていた。
スキーマの回帰テストを足したとき、筆者はまずわざと型注釈を消して、テストが落ちることを確かめた。落ちない安全網は、掛けていないのと同じだから。この確認を挟んでいなければ、「テストを足した」という記録だけが残って、次に同じ穴を踏んだときに何も鳴らない。
もう1つ、レビューで指摘されて肝が冷えた箇所がある。エンジンのテストで使う偽モデルが、受け取った引数をクラス変数に記録していた。
# 危ない書き方:インスタンスをまたいで値が残る
class _FakeModel:
last_kwargs = {} # クラス変数=全テストで共有
def transcribe(self, path, **kwargs):
_FakeModel.last_kwargs = kwargs
...
今のテストはどれも transcribe() を呼ぶので、値は毎回上書きされて問題が出ない。呼ばないテストを1本足した瞬間に、前のテストが残した引数を読んで「合格」する。落ちるべきときに緑になるテストは、無いより悪い。インスタンス変数に直して、各テストの前に初期化するのが正しい。
lint と型の緑も自分の手で測ったほうがいい。エディタ側の診断は、仮想環境の外のインタプリタを見ていると、import が解決できないだけで赤く光る。逆に新しいリポジトリに [tool.ruff] を書き忘れると、マシン全体に置いた厳しめの設定が拾われて、プロジェクトの実態と違う基準で判定される。
既存ツールがあるなら書き直さない
scraper-mcp では設計を変えた。X検索もYouTubeチャット収集も、手元に動くツールがすでにあった。中身をライブラリとして取り込み直す手もあったが、subprocess で既存ツールを起動するだけで済ませた。
結果として scraper-mcp 自身は chat_downloader も yt_dlp も依存に持たない。呼ぶだけの薄い層なので、向こう側が更新されても追従の手間がほとんどない。MCPサーバーは「新しく作る場所」というより「すでにある資産に口を付ける場所」だと考えると、この形が素直に収まる。
逆に whisper-mcp はエンジンを新規に選び直した。既存の文字起こしツールはあったものの、GPUで大きいモデルを常用したかったので、そこは作り直す価値があると判断した。
Claude Code につなぐ
接続は1行で済む。-s user を付けるとユーザー全体のスコープに入るので、プロジェクトを移っても使える。
claude mcp add whisper-mcp -s user -- uv --directory C:\Users\Username\MY_Work_Space\whisper_mcp run whisper-mcp
claude mcp list で Connected を確認する。実際にツールが見えるのは Claude Code を再起動してからだ。
設定は環境変数に寄せた。コードを触らずに挙動を変えられるので、モデルを落としてVRAMに合わせたいときにすぐ試せる。
| 変数 | 既定 | 効果 |
|---|---|---|
WHISPER_MCP_CACHE |
E:\ModelCache\faster-whisper |
モデルの保存先 |
WHISPER_MCP_MODEL |
large-v3 |
既定モデル。VRAMが足りなければ medium へ |
WHISPER_MCP_LANG |
ja |
既定言語 |
WHISPER_MCP_CONCURRENCY |
1 |
ジョブの並列数 |
WHISPER_MCP_FFMPEG |
ffmpeg |
動画から音声を抜くコマンドの場所 |
WHISPER_MCP_OUTPUT_ROOT |
入力と同じ場所の whisper_out/ |
字幕ファイルの書き出し先 |
WHISPER_MCP_VAD |
0 |
無音区間の除去(前述のとおり既定は切ってある) |
数GBのモデルをシステムドライブに置きたくなかったので、キャッシュ先を外から差し替えられる形にしたのは正解だった。出力は srt / vtt / txt がファイル書き出しで、パスだけを返す。json はそのまま本文を返す。字幕は編集ソフトへ渡すことが多く、本文はその場で読みたい、という使い分けだ。
心残りもある。出力形式の検証が、文字起こしが終わってから走る。形式名を打ち間違えると、GPUを数十秒回しきったあとで弾かれる。開始の時点で弾く形に直すのが、次の宿題のもう1つだ。
何が変わったか
いちばん効いたのは、動画を見て要約するプラグインの文字起こしを、このサーバーに差し替えたことだ。もともとはクラウドの文字起こしAPIを叩く作りで、APIキーが要る。そこをローカルの whisper-mcp に向けたので、キーの登録なしに、手元のGPUで日本語の音声が文字になるようになった。
部品として切り出しておくと、こういう転用が効く。文字起こしが要る場面は動画に限らないので、次に何かを作るときも同じ口をそのまま使える。
話者分離とジョブの永続化、HTTPトランスポートは v2 に送った。全部入りを目指すと完成しないので、動かして使い始めるところまでを先に閉じた。
依存するもののライセンス
自分で使うだけなら気にしなくていい。作ったサーバーを配ったり、仕事で使ったりする段になると効いてくるので、LICENSE 本文を読んで確かめた結果を置いておく(2026-07-31 時点)。
| 依存先 | ライセンス | 実務での注意 |
|---|---|---|
| faster-whisper | MIT(Copyright (c) 2023 SYSTRAN) | 著作権表示を残せば商用も改変も可 |
| MCP Python SDK | MIT(Copyright (c) 2024 Anthropic, PBC) | 同上 |
| large-v3 の重み | MIT | 各自がHugging Faceから取得する。再配布はしない |
| FFmpeg | LGPL 2.1+(ビルド次第でGPL 2+) | 下記のとおり注意が要る |
気を付けるのは FFmpeg だけだ。土台は LGPL 2.1 以降でも、--enable-gpl を付けてビルドされたものを使うと FFmpeg 全体に GPL が及ぶ。配布サイトで拾ってきたバイナリがどちらのビルドかは、見た目では分からない。
whisper-mcp は FFmpeg を別プロセスとして起動するだけで、コードに組み込んでいない。この形なら自分のコードがGPLに引きずられる心配は薄い。実行ファイルに同梱して配る作りに変える場合は、そのビルドのライセンスを確認してからにしたほうがいい。
まとめ:筆者ならこう作る
ローカルの重い処理をMCPサーバーにするなら、最初にジョブ方式で組む。1回で返す形から後で移行するのは、ツールの数も呼び出し側の手順も変わるので面倒になる。数秒で終わる処理しか無いと確信できるとき以外は、start / status / result の3本に割っておいたほうがいい。
そして引数の型注釈は必ず書く。自分で使う分には注釈が無くても動いてしまうので、壊れていることに気づけない。公開スキーマは実行時の挙動とは別物で、外から見える仕様のほうがツールの価値を決める。筆者は2本続けて同じ穴に落ちた。
手元に動くツールがあるなら、まず subprocess で口を付けるところから試すのが早い。取り込み直すのは、作り直す理由がはっきりしてからでいい。
まずは自分がいちばん頻繁に手で回している処理を1つ選んで、ツール3本ぶんのMCPサーバーにしてみてほしい。手で回さなくなった時点で、作った価値は回収できている。
📚 関連記事:文字起こしそのものの精度と使い分けはWhisperで音声を文字にするとWhisper と Parakeet を日本語で比べるに書いた。複数のAIエージェントを1画面に束ねる話はClaude Mission Control の作り方へ。ほかの自作アプリは開発実例ギャラリーから。
