『Typewriter — 代筆屋をつくる』の最後の方で、MCP連携に2週間ハマった話をちらっと書いた。この記事はその2週間の中身。
構成は前の記事で書いた通り。中央にMONET(オーケストレーター)がいて、その下にTypewriterがぶら下がる。この指示と応答の通り道がMCP(Model Context Protocol。AnthropicがClaudeとツールを繋ぐために作った標準プロトコル)。
選択肢は他にもあった。HTTP APIで自前の通信規格を作ってもいいし、gRPCでも普通のRESTでも別にいい。でもMCPを選んだ理由は単純で、Anthropicが仕様を引いているから、Claudeとの相性が一番良いはず、と思ったから。標準に乗っかっておけば、将来アプリが増えた時も同じパターンで繋げられる。この時点でHandlerやLibrarianを足していくイメージはぼんやりあって、アプリ追加のたびに通信規格から考え直すのは避けたかった。
とにかくMCPで行く、と決めた。でもそこからが長かった。
コーディングしてくれているClaude Code(以降Code)に頼んで、まずはAnthropic APIの公式ドキュメントに載っている方法を試した。client.beta.messages.create に mcp_servers パラメータを渡して、ローカルに立てたMCPサーバーのURL(http://localhost:8002/mcp/)を教える方式。公式ドキュメント通りの書き方。
結果は500エラー。
サーバーは立ち上がってる。URLも合ってる。なのに500。この時点ではまだ「何かの設定ミスだろう」と楽観してた・・・。
Codeが調べて返してきた答えで、当初の構成が崩れた。Anthropic APIの mcp_servers パラメータは、リモート(公開されている)MCPサーバー専用。ローカルホストには接続しない仕様。ドキュメントのどこかに書いてあったんだろうけど、見落としていた。
MONETはAnthropic APIで動いてる。そしてそのAPIは、ローカルのMCPサーバーに繋がらない。じゃあどうするんだ、という話になる。
Codeが別の道を探しに行った。FastMCPというライブラリにClientクラスがあって、stdio経由でローカルのMCPサーバーに接続できるらしい、という情報を拾ってきた。
stdio方式というのは、標準入力・標準出力を使った通信。HTTP経由じゃなくて、MONET側からMCPサーバーのスクリプトを子プロセスとして起動して、そのプロセスの入出力にJSON-RPCを流す。リモートサーバー前提のAnthropic APIの仕組みとは全然違う。
この構成に入れ替えた時点で、もうMVPから数日経っていた。ここまでは「設計ミスを直す」フェーズ。ここから「直した設計が実際に動くか」のフェーズに入る。
stdio方式で立ち上げてみた。繋がる。ツール一覧も返ってくる。じゃあツールを呼んでみる。
壊れる。
Codeと原因を調べていくと、MCPのstdioモードでは stdoutがJSON-RPCプロトコル専用 だった。標準出力に余計な文字列を一文字でも出すと、プロトコルが壊れる。
で、僕のコード、db.py の初期化のところに print("[DB] データベース初期化完了") って書いてあった。テスト中にデバッグ目的で入れたやつ。それがずっと残っていて、MCPサーバー起動のたびに標準出力に出て、JSON-RPCに混じってパースエラーを起こしていた。
今思えば当たり前の話なんだけど、その時は全然わからなかった。「なんでツール呼び出しだけ失敗するの?」状態。
Codeが出してきた修正方針は力技で、mcp_server.py の先頭で、インポート前に標準出力を抑制して、全部のimportが終わってから復元する。
from io import StringIO
_original_stdout = sys.stdout
sys.stdout = StringIO() # 抑制
# imports...
sys.stdout = _original_stdout # 復元
print文が混じってもStringIOのバッファに吸い込まれて、標準出力には出ない。これで通った。
stdoutが片付いたら、今度はデータベース。
MONET側(VoiceChatBot)からMCP経由でTypewriterのMCPサーバーを起動すると、カレントディレクトリがVoiceChatBotの方になる。Typewriterの db.py がデータベースファイルを相対パスで指定していたせいで、sns_app.db がVoiceChatBotのディレクトリに作られる。
つまり、DBが2つできる。TypewriterのUIを直接開いた時に書き込むDBはTypewriterのディレクトリ。MONETがMCP経由で書き込むDBはVoiceChatBotのディレクトリ。別の場所。
これに気づいた時は地味にショックだった。「下書きを作った」とMONETが言う。Typewriterを開くと下書きがない。でも別のDBファイルにはちゃんと入っている。データは消えてないのに、どこにも表示されない。
修正はDBパスを相対パスから絶対パスに変えるだけ。
# 修正前
def __init__(self, db_path: str = "sns_app.db"):
# 修正後
DEFAULT_DB_PATH = Path(__file__).parent / "sns_app.db"
def __init__(self, db_path: str = None):
self.db_path = str(db_path) if db_path else str(DEFAULT_DB_PATH)
Path(__file__).parent で、db.py が置いてあるディレクトリを基準に絶対パスを作る。どこから起動されても、同じ sns_app.db を見る。VoiceChatBot側に誤って作られた方のDBファイルは消して、書き込み先が1つに統一された。
その後も小さいバグが次から次に出てくる。
post_to_x 関数が普通の def で定義されていて、中のasync関数を await なしで呼んでいた。これだと実際には実行されなくて、コルーチンオブジェクトが返ってくるだけ。Codeが async def + await に書き換えた。
次は引数の型。db.create_post() は content と status しか受け付けないのに、MCPサーバー側から x_post_id まで渡そうとしていた。create_post で保存して、そのあと mark_post_as_posted で x_post_id を更新する2ステップに分ける形に直してもらった。
次はシステムプロンプトにX投稿のツールの使い方が書いてない問題。次は ClaudeAgentOptions に tools=[...] が渡ってない問題。このへんはもう、出てきたそばからCodeに渡して潰してもらう流れ作業になっていた。
一個直すと次のが出てくる。MCPの通信自体は動いてるのに、その先の連携がことごとく壊れてる。エンジニアなら「当たり前のデバッグ作業」と言うかもしれない。でも僕はエンジニアじゃない。直してもらって、動かして、また別のところが壊れて、というループを何回も回した。
で、2週間経った頃、全部動いた。
フルワークフローのテストを流す。下書き作成、ID:1で保存される。一覧取得、1件返ってくる。削除、消える。最終確認、「下書きはありません」。
MONETに「Typewriterで下書き作って」と言う。MONETがMCP経由でTypewriterのMCPサーバーを起動する。create_draft が呼ばれる。DBに書き込まれる。結果がJSON-RPCで戻ってくる。MONETが「作りました」と僕に答える。Typewriterの画面を開くと、下書きが載ってる。
やっと通った。
MCPはこのシステムの神経網。脳(MONET)だけ立派でも、神経が通っていないと手足(アプリ群)が動かない。神経が通った瞬間、別々の臓器でしかなかったMONETとTypewriterが、一つの体になった感じがした・・・。2週間、ひたすらこの神経を通すことだけやっていた。
2週間。コードの差分で言えば大した量じゃない。stdout抑制を入れて、DBパスを絶対パスにして、async/awaitを直して、引数の型を揃えて。それだけ。
でもこの2週間で掴んだのは、差分の大きさとは別のところにあった。
1つは「標準プロトコルに乗ったら楽、じゃない」という感覚。標準に乗ったうえで、stdioモードの罠、DBパスの問題、async/await、全部自分で通す。もう1つは、アプリを増やす時の手順が見えたこと。MCPManager クラスに新しいサーバーを登録して、app_sdk.py にツール定義を追加する。それだけで、新しいアプリをMONETから呼べる。次はHandlerかLibrarianを足す、というのが頭の中で具体になりはじめた。
一つだけ、頭に引っかかってることがある。Typewriterの中にClaudeがいて、MONETの中にもClaudeがいる。同じClaudeが2箇所にいて、MCP経由で喋ってる。この構成で本当にいいのか、まだ自分の中で答えが出てない。
でも今日のところは、神経が通った。「やった」というよりは「やっと終わった」寄りの気分だと思う。