アプリの作り方

自作MCPサーバーの作り方 — 長い処理はジョブ方式で公開する

筆者が作った whisper-mcp のツールは5つある。うち3つ(transcribe_start / transcribe_status / transcribe_result)は、1回の文字起こしを3回に分けて呼ぶためだけに存在する。1つのツールで「音声を渡したら文字が返る」ほうが素直に見える。それでも分けたのには理由があって、そこがローカルの重い処理をMCPサーバーにするときの最初の分かれ道だった。この記事では、実際に動いている2本のMCPサーバーの設計と、公開スキーマで踏んだ失敗をそのまま書く。

目次
  1. この記事で作るもの
  2. 最初の判断:ツールを1回で完結させない
  3. フォルダを渡せるようにする
  4. モジュールの割り方
  5. つまずいた話①:公開スキーマが嘘をついていた
  6. つまずいた話②:VADは無条件の改善ではない
  7. テストの緑を信じない
  8. 既存ツールがあるなら書き直さない
  9. Claude Code につなぐ
  10. 何が変わったか
  11. 依存するもののライセンス
  12. まとめ:筆者ならこう作る

この記事で作るもの

題材は、筆者が実際に作って今も 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 だけを即座に返し、本体はバックグラウンドで走らせる。呼び出し側は状態を聞きに行き、終わっていたら結果を取りに行く。

図1:ジョブ方式の3段階transcribe_startjob_id を即返すtranscribe_status何件終わったかtranscribe_result本文・出力パスバックグラウンド:ThreadPoolExecutor が音声を1件ずつ処理(動画は ffmpeg で音声を抜いてから)1件失敗しても残りは続行。cancel_job でいつでも止められる
図1:長い処理は「受付」「進捗」「受取」に割る。呼び出し側は待たされず、途中で中止もできる。

ジョブの入れ物は素朴でいい。筆者はメモリ上の辞書とロックだけで始めた。

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枚しか積んでいないのに複数ジョブを同時に走らせても、取り合いになって遅くなるだけだから。

JobRegistryProtocol で定義しておいた点は、後から効いた。永続化を 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 が使えるなら cudafloat16、無ければ cpuint8 に落とす。エンジンに 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 でもやっていた。あちらは limitstring で公開されていた。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_downloaderyt_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 の作り方へ。ほかの自作アプリは開発実例ギャラリーから。

Claude Codeのトークン消費を減らす実践ガイド — 公式ドキュメントで裏を取った、効く順の手当て前のページ

Claude Codeのトークンを削るOSS 11本 — ライセンス本文まで確かめた一覧次のページ

ピックアップ記事

  1. Claude Mission Control の作り方 — Tauri+Pyth…

  2. 高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyIns…

  3. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…

  4. New Eden Intelligence Hub の作り方 — EVE Onl…

関連記事

  1. 日本語の文字起こし、Whisperより良いモデルはあるのか。測り方を3回間違えながら比べた記録
  2. Claude Mission Control の作り方 — Tauri+Python+MCPで複数AIエージェントを1画面に統合する

    アプリの作り方

    Claude Mission Control の作り方 — Tauri+Python+MCPで複数A…

    複数AIエージェントの会話・生成・成果物を1画面に統合するデスクトップ…

  3. ゲーム感覚でIT・AI用語500選を覚える学習アプリの作り方 — XP・13ランク・実績で「続く」を設計する
  4. AIアセットでゲームマップを作る — 生成スケッチの透過切り出しからレベルセレクトまで
  5. JavaScriptでブラウザゲームを作る①|ビルド不要(file://)で動くシーン制の土台と全体設計【Archipelago Saga制作】
  6. 都市開発シミュを作る④|4度つまずいて座標系ごと作り直した話 — 真のアイソメグリッド移行【Aurum City制作】

注目

AIで、ここまで作れる

AIで作った2D RPGを、ブラウザでそのまま遊べます。その「作り方=最後まで完成させる進め方」も実例つきで公開中。

▶ ゲームを遊ぶやり方を読む

PR

ロリポップ!レンタルサーバー(PR)

レンタルサーバ:ロリポップ!(本サイトの稼働環境・PR)

  1. AIに「作りたい」を正しく伝える3つのコツ|プロンプト入門【入門3】

    バイブコーディング

    AIに「作りたい」を正しく伝える3つのコツ|プロンプト入門【入門3】
  2. Grokで回すXメディア生成パイプラインの作り方 — 検索から画像・音声・動画まで自動化する

    アプリの作り方

    Grokで回すXメディア生成パイプラインの作り方 — 検索から画像・音声・動画ま…
  3. 背景除去アプリを作る⑤|5スライダー後処理+消去/復元ブラシ+20段Undoで「自動9割・手で1割」を仕上げる【背景除去Studio制作】

    アプリの作り方

    背景除去アプリを作る⑤|5スライダー後処理+消去/復元ブラシ+20段Undoで「…
  4. ブラウザで動く都市開発シミュを作る①|React+TypeScript+Canvasの土台と3レイヤ設計【Aurum City制作】

    アプリの作り方

    ブラウザで動く都市開発シミュを作る①|React+TypeScript+Canv…
  5. Kimi K3 の実費を見る — 表示価格より推論トークンとキャッシュヒットで決まる

    AI・テック動向

    Kimi K3 の実費を見る — 表示価格より推論トークンとキャッシュヒットで決…
PAGE TOP

TAG CLOUD

ドラッグで回転・クリックでそのタグの記事一覧へ