BLOG.HIRO UENO.ART
← index
2026.01.18

MCPという通信基盤

『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.createmcp_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 defawait に書き換えた。

次は引数の型。db.create_post()contentstatus しか受け付けないのに、MCPサーバー側から x_post_id まで渡そうとしていた。create_post で保存して、そのあと mark_post_as_postedx_post_id を更新する2ステップに分ける形に直してもらった。

次はシステムプロンプトにX投稿のツールの使い方が書いてない問題。次は ClaudeAgentOptionstools=[...] が渡ってない問題。このへんはもう、出てきたそばから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経由で喋ってる。この構成で本当にいいのか、まだ自分の中で答えが出てない。

でも今日のところは、神経が通った。「やった」というよりは「やっと終わった」寄りの気分だと思う。