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つです。

やり取りの流れは次のとおりです。

順番クライアント(Claude Code)→ サーバーサーバーの返事
1initialize(使いたいプロトコルの版を伝える)使う版、持っている機能(toolsなど)、サーバー名
2notifications/initialized(通知)返事をしない(通知にはidがない)
3tools/listツールの一覧(名前、説明、入力のJSON Schema)
4tools/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つです。

まずはサーバー単体で動作を確かめる

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版で確認)

カテゴリ
タグ
日付
2026年1月