MCPサーバーをPythonで自作してClaude Codeにつなぐ:標準ライブラリだけの最小構成と「つながらない」原因
MCP(Model Context Protocol)のサーバーは、公式SDKを使えば短く書けます。ただ、SDKを入れられない環境だったり、「中で何が起きているのか」が分からないまま動かしていて、claude mcp addしたのにつながらない理由が見えなかったりすることもあります。この記事では、Pythonの標準ライブラリだけで動く最小のMCPサーバー(約100行)を作り、Claude Codeに登録して接続するまでを、実際に試した結果とエラー文つきで解説します。
検証環境:Claude Code 2.1.300/Python 3(標準ライブラリのみ、追加インストールなし)/WSL2(Ubuntu)。2026年10月16日に、サーバー単体へのメッセージ送信テスト10件と、claude mcp add・claude mcp listによる接続テストを行いました。仕様はMCP公式の2025-06-18版で確認しています。
MCPサーバーは「1行1JSONで話すプログラム」
Claude Codeが手元のサーバーとやり取りするときの方式(stdio)は、とても単純です。Claude Codeがサーバーのプログラムを起動し、標準入力にJSON-RPCのメッセージを1行ずつ書き込み、サーバーは標準出力に1行ずつ返事を書きます。守るべき決まりは次の3つです。
- 標準出力には、MCPのメッセージ(1行に1つのJSON)だけを書く。メッセージの中に改行を入れない
- ログは標準エラー出力(stderr)に書く。普段の
print()は標準出力に出るので要注意 - 最初に初期化のやり取りをする。クライアントからの
initializeに返事をし、notifications/initializedの通知を受けてから本番のやり取りに入る
やり取りの流れは次のとおりです。
| 順番 | クライアント(Claude Code)→ サーバー | サーバーの返事 |
|---|---|---|
| 1 | initialize(使いたいプロトコルの版を伝える) | 使う版、持っている機能(toolsなど)、サーバー名 |
| 2 | notifications/initialized(通知) | 返事をしない(通知にはidがない) |
| 3 | tools/list | ツールの一覧(名前、説明、入力のJSON Schema) |
| 4 | tools/call(ツール名と引数) | 結果(contentの配列) |
標準ライブラリだけの最小サーバー
文章の文字数を数えるcount_textと、指定したタイムゾーンの現在時刻を返すnowの2つのツールを持つサーバーです。外部のパッケージは使っていないので、Python 3.9以上ならそのまま動きます。
# mini_server.py
#!/usr/bin/env python3
"""標準ライブラリだけで動く最小のMCPサーバー(stdio)
- stdout にはMCPのメッセージ(1行1JSON)だけを書く
- ログは必ず stderr に書く
"""
import json
import sys
from datetime import datetime
from zoneinfo import ZoneInfo
SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]
TOOLS = [
{
"name": "count_text",
"description": "文章の文字数・行数・全角文字の数を数える",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string", "description": "数えたい文章"}},
"required": ["text"],
},
},
{
"name": "now",
"description": "指定したタイムゾーンの現在時刻を返す(既定は Asia/Tokyo)",
"inputSchema": {
"type": "object",
"properties": {"tz": {"type": "string", "description": "例: Asia/Tokyo, UTC"}},
},
},
]
def log(msg):
print(f"[mini-server] {msg}", file=sys.stderr, flush=True)
def send(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
sys.stdout.flush()
def text_result(text, is_error=False):
return {"content": [{"type": "text", "text": text}], "isError": is_error}
def call_tool(name, args):
if name == "count_text":
t = args.get("text", "")
wide = sum(1 for c in t if ord(c) > 0x7F)
return text_result(f"文字数: {len(t)} / 行数: {len(t.splitlines()) or 0} / 全角など: {wide}")
if name == "now":
tz = args.get("tz", "Asia/Tokyo")
try:
return text_result(datetime.now(ZoneInfo(tz)).isoformat(timespec="seconds"))
except Exception:
# ツールの実行エラーは isError で返す(プロトコルエラーにしない)
return text_result(f"不明なタイムゾーンです: {tz}", is_error=True)
return None
def handle(msg):
method, mid = msg.get("method"), msg.get("id")
if mid is None: # 通知(notifications/initialized など)には返事をしない
log(f"notification: {method}")
return
if method == "initialize":
requested = msg.get("params", {}).get("protocolVersion")
version = requested if requested in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0]
send({"jsonrpc": "2.0", "id": mid, "result": {
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "mini-server", "version": "0.1.0"},
}})
elif method == "ping":
send({"jsonrpc": "2.0", "id": mid, "result": {}})
elif method == "tools/list":
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}})
elif method == "tools/call":
p = msg.get("params", {})
result = call_tool(p.get("name"), p.get("arguments") or {})
if result is None:
send({"jsonrpc": "2.0", "id": mid,
"error": {"code": -32602, "message": f"Unknown tool: {p.get('name')}"}})
else:
send({"jsonrpc": "2.0", "id": mid, "result": result})
else:
send({"jsonrpc": "2.0", "id": mid,
"error": {"code": -32601, "message": f"Method not found: {method}"}})
def main():
log("started")
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
handle(json.loads(line))
except json.JSONDecodeError:
send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}})
if __name__ == "__main__":
main()
押さえておきたい点は3つです。
- 版の決め方:クライアントが求めた版に対応していればその版を、対応していなければ自分が使える最新の版を返します。これは仕様で決められた動きです。
- エラーは2種類:存在しないツールのように「呼び出し方がおかしい」場合はJSON-RPCの
error(-32602)で返します。ツールは呼べたが処理に失敗した場合は、通常の結果として"isError": trueを付けて返します。後者ならClaudeがエラー内容を読んで、自分でやり直せます。 - 通知には返事をしない:
idのないメッセージは通知なので、何も書き返しません。
まずはサーバー単体で動作を確かめる
Claude Codeにつなぐ前に、メッセージを直接流して確かめるのが近道です。つながらないとき、原因がサーバーにあるのか登録にあるのかを切り分けられます。
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"count_text","arguments":{"text":"こんにちは\nAI"}}}' \
| python3 mini_server.py
実際の応答(抜粋)です。
{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {"listChanged": false}}, "serverInfo": {"name": "mini-server", "version": "0.1.0"}}}
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "文字数: 8 / 行数: 2 / 全角など: 5"}], "isError": false}}
テスト結果(10件すべて期待どおり):要求した版をそのまま返す/tools機能を宣言する/通知には返事をしない/ツールが2つ並ぶ/文字数を正しく数える/不明なタイムゾーンはisError: true/未知のツールは-32602/未対応の版(1999-01-01)には自分の最新版を返す/標準出力はすべてJSON/ログは標準エラー出力に出る。
Claude Codeに登録する
claude mcp add mini -- python3 /絶対パス/mini_server.py
claude mcp list
登録できると、claude mcp listで次のように表示されます(パスは省略)。
mini: python3 /…/mini_server.py - ✔ Connected
登録先(スコープ)は3つあります。何も指定しないとlocalになり、そのプロジェクトで自分だけが使えます。
| スコープ | 保存先 | 使いどころ |
|---|---|---|
local(既定) | ~/.claude.jsonのそのプロジェクト分 | 自分だけ・このプロジェクトだけで試す |
project | プロジェクトの.mcp.json | チームで共有する(gitに入れる) |
user | ~/.claude.json | 自分のすべてのプロジェクトで使う |
「claude mcp add できない・つながらない」原因と対処
以下は、どれも検証環境で実際に起こしたエラーです。
1. -eを名前の前に置いて失敗する
$ claude mcp add -s project -e FOO=bar envtest -- python3 mini_server.py
Invalid environment variable format: envtest, environment variables should be added as: -e KEY1=value1 -e KEY2=value2
-eは複数の値を受け取れるオプションなので、直後のサーバー名(envtest)まで環境変数として読まれてしまいます。環境変数はサーバー名の後ろに書きます(claude mcp add envtest -e FOO=bar -- python3 mini_server.py)。
2. --を付けずにサーバー側のオプションを渡して失敗する
$ claude mcp add -s project noflag python3 mini_server.py --verbose
error: unknown option '--verbose'
サーバーに渡すつもりの--verboseが、claude自身のオプションとして解釈されています。サーバーの起動コマンドの前に--を入れると、それ以降はそのままサーバーに渡ります。
3. コマンド名が見つからない
wrongcmd: python /…/mini_server.py - ✘ Failed to connect — ENOENT: Executable not found in $PATH: "python"
Ubuntuなどではpythonコマンドがなく、python3しかないことがよくあります。Windowsでは逆にpythonやpyです。which python3で確かめ、確実を期すなら絶対パス(例:/usr/bin/python3)を書きます。
4. ファイルのパスが間違っている
broken: python3 /…/no_such_file.py - ✘ Failed to connect — CONNECTION_CLOSED: Connection closed
コマンド自体は起動できても、サーバーがすぐ終了すると「接続が閉じた」とだけ表示されます。原因が分かりにくいので、登録したのと同じコマンドを端末で直接実行して、エラーが出ないか確かめてください。相対パスは、起動時の作業ディレクトリ次第で壊れるため、絶対パスが無難です。
5. projectスコープで登録したのに「Pending approval」のまま
mini: python3 /…/mini_server.py - ⏸ Pending approval (run `claude` to approve)
.mcp.json(projectスコープ)のサーバーは、他人が仕込んだサーバーを勝手に動かさないよう、最初に利用者の承認が必要です。claudeを起動すると承認の確認が出るので、中身を確かめてから承認します。承認するまで、このサーバーのツールは使えません。
6. 標準出力にログを書いてしまう
起動時にprint("server starting...")のように標準出力へ書くサーバーも試しました。この検証では、claude mcp listの確認は「✔ Connected」と通りました。ただし仕様では「標準出力にはMCPのメッセージ以外を書いてはならない」とされています。クライアントやタイミングによってはメッセージの読み取りが壊れる原因になるので、ログは必ずprint(..., file=sys.stderr)で標準エラー出力へ書いてください。
本番では公式SDKも検討する
標準ライブラリ版は仕組みを理解するのに向いていますが、本格的に使うなら公式のPython SDK(mcpパッケージ)も選択肢です。SDKのFastMCPを使うと、関数にデコレーターを付けるだけでツールを定義でき、入力スキーマも型ヒントから作られます。リソースやプロンプト、HTTPでの提供など、この記事の最小構成にない機能も揃っています。なお、SDK版はこの記事の検証環境では試していません。
どちらで作るにしても、ツールはClaudeが自動で呼ぶものです。ファイルの削除や外部への送信のような操作をツールにするなら、引数の検証や確認の仕組みを必ず入れてください。MCPの仕様も、利用者が呼び出しを拒否できるようにすることを求めています。
まとめ
MCPサーバーの正体は「標準入出力で1行ずつJSONを読み書きするプログラム」で、初期化・一覧・呼び出しの3つに答えられれば動きます。つながらないときは、(1) サーバー単体にメッセージを流して応答を確かめ、(2) 登録したコマンドを端末で直接動かし、(3) claude mcp listの表示(Connected/Failed/Pending approval)を見る、の順で切り分けると、原因に早くたどり着けます。
関連記事:Claude Code Skillsの作り方 | Claude Code hooksの設定例8選 | エージェントを外部につなぐ前のチェックリスト | 「MCPは使わない」からの方針転換
公式ドキュメント:MCP Lifecycle | MCP Tools | Claude Code MCP
最終確認日:2026年10月16日(Claude Code 2.1.300、MCP仕様 2025-06-18版で確認)