1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自作の MCP サーバーを Claude Code につなぐ最小手順(mcp 2.x では FastMCP が MCPServer に変わっていた)

1
Last updated at Posted at 2026-11-01

何に困ったか

Claude Code に自作の道具を持たせる例として、「文章の文字数を数える」「Markdown の見出しを取り出す」MCP サーバーを Python で書いて試しました。
ネットの解説どおり from mcp.server.fastmcp import FastMCP と書いたところ、動きませんでした。mcp 2.x では FastMCP が MCPServer に名前を変えていたためです。

この記事では、35行ほどのサーバーと、動作確認のクライアント、claude mcp add でつないで「Connected」になるまでを、実際に動かした結果つきで書きます。
会話の中で Claude がツールを呼ぶところまでは確かめていません(最後の「確認できていないこと」に書きました)。

環境

  • Mac(Apple Silicon)、Python 3.15(venv)、mcp 2.3.0
  • Claude Code 2.1.304
  • 2026-11-01 に、この記事のコマンドとコードをそのまま実行して確かめました

1. インストール

pip と python3 が別の Python を指していて、入れたのに import mcp が通らないことがありました(最初に試した環境では pip が 3.13、python3 が 3.11)。
venv を作って、そのなかの pip を使うと、向き先がずれにくくなります。

python3 -m venv venv
./venv/bin/pip install mcp
./venv/bin/pip list | grep -i "^mcp"
mcp                       2.3.0
mcp-types                 2.3.0

2. つまずき: FastMCP が見つからない

古い解説の書き方を、そのまま2.x で動かしたときのエラーです。

./venv/bin/python -c "from mcp.server.fastmcp import FastMCP"
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver or pin 'mcp<2' to keep running v1 code.. Did you mean: 'mcp.server.auth'?

エラー文が直し方を教えてくれます。選択肢は2つです。

  • 2.x の書き方にする: from mcp.server.mcpserver import MCPServer
  • 古いコードのまま動かす: mcp<2 に固定する(シェルでは < がリダイレクトになるので pip install 'mcp<2' のように引用符で囲む。この記事では試していません)

ここでは2.x の書き方で進めます。

3. サーバー: server.py

ツールは3つです。@mcp.tool() を付けた関数が、そのままツールになります。

"""文章チェック用の小さな MCP サーバー(mcp 2.x / stdio)。ツール3つ。"""
import re
from mcp.server.mcpserver import MCPServer

mcp = MCPServer("text-checker")


@mcp.tool()
def count_chars(text: str) -> dict:
    """文字数を数える(空白・改行を除いた数と、含めた数)。"""
    return {"with_space": len(text), "without_space": len(re.sub(r"\s", "", text))}


@mcp.tool()
def extract_headings(markdown: str) -> list[str]:
    """Markdown から見出し行(# で始まる行)を順に取り出す。コードブロック内は除く。"""
    out, in_code = [], False
    for line in markdown.splitlines():
        if line.strip().startswith("```"):
            in_code = not in_code
            continue
        if not in_code and re.match(r"#{1,6}\s+\S", line):
            out.append(line.strip())
    return out


@mcp.tool()
def reading_minutes(text: str, chars_per_min: int = 500) -> float:
    """日本語の読む時間(分)。既定は1分500字として計算。"""
    n = len(re.sub(r"\s", "", text))
    return round(n / chars_per_min, 1)


if __name__ == "__main__":
    mcp.run("stdio")

関数の docstring が、Claude から見たツールの説明になります。何をするツールか、1行で書いておきます。

4. 動作確認: Claude Code につなぐ前に、公式クライアントで

Claude Code につなぐ前に、MCP の公式クライアントで stdio 接続して、ツール一覧と3つの呼び出しを確かめます。server.py と同じフォルダに test_client.py として保存します。

"""stdio で server.py をつないで、ツール一覧と3つの呼び出しを確かめる。"""
import asyncio, sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

SAMPLE = "# 題\n本文です。 二行目\n```\n# コード内\n```\n## 小見出し\nあいうえお"

async def main():
    params = StdioServerParameters(command=sys.executable, args=["server.py"])
    async with stdio_client(params) as (r, w):
        async with ClientSession(r, w) as s:
            await s.initialize()
            tools = await s.list_tools()
            print("tools:", [t.name for t in tools.tools])
            for name, args in [("count_chars", {"text": SAMPLE}),
                               ("extract_headings", {"markdown": SAMPLE}),
                               ("reading_minutes", {"text": "あ" * 1250})]:
                res = await s.call_tool(name, args)
                print(name, "->", res.is_error,
                      [c.text for c in res.content], res.structured_content)
asyncio.run(main())
./venv/bin/python test_client.py
tools: ['count_chars', 'extract_headings', 'reading_minutes']
count_chars -> False ['{\n  "with_space": 42,\n  "without_space": 32\n}'] None
extract_headings -> False ['# 題', '## 小見出し'] {'result': ['# 題', '## 小見出し']}
reading_minutes -> False ['2.5'] {'result': 2.5}
  • count_chars: 見本の文字列を Python で別に数えた長さも 42 で、一致しました
  • extract_headings: コードブロックの中の # コード内 は拾われず、見出し2つだけ返りました
  • reading_minutes: 1250字(「あ」×1250)で 2.5 分でした

5. つまずき: 戻り値の入り方が型で変わる

2.x では、戻り値が structured_content と本文(content の text)のどちらに入るかが、型で違いました。上の出力のとおりです。

  • list[str] や float を返す → structured_content に {'result': ...} で入る
  • dict を返す → 本文に JSON 文字列で入り、structured_content は None

クライアント側を書くときに、structured_content だけを見ていると、dict を返すツールで空振りします。

もう1つ、属性の名前にも注意が要ります。2.x の結果は res.is_error・res.structured_content のようにスネークケースです。仕様書(JSON)の名前どおり res.isError・res.structuredContent と書くと、AttributeError: 'CallToolResult' object has no attribute 'isError'. Did you mean '.is_error' instead of '.isError'? で止まりました。

6. Claude Code につなぐ

claude mcp add text-checker -- /絶対パス/venv/bin/python /絶対パス/server.py
claude mcp list
Checking MCP server health…

text-checker: /絶対パス/venv/bin/python /絶対パス/server.py - ✔ Connected

/絶対パス/ は自分の venv と server.py の場所に置き換えます(出力のパスも伏せています)。
claude mcp add の後ろの -- 以降が、サーバーを起動するコマンドです。python は venv のものを絶対パスで指定しています(ここを python3 にすると、入れた場所と違う Python が動くことがあります)。
確かめたあとは、設定を戻しました。

claude mcp remove text-checker

確認できていないこと

  • Claude Code の会話の中で、Claude が実際にこのツールを呼ぶところは、今回は確かめていません。ここまでで確かめたのは、公式クライアントからの呼び出しと、claude mcp list での Connected までです
  • mcp<2 に固定する方法は、エラー文に書かれていたとおり紹介しただけで、試していません

まとめ

  • mcp 2.x では FastMCP が MCPServer になり、from mcp.server.mcpserver import MCPServer で書く
  • 古い解説をそのまま貼ると ModuleNotFoundError。結果の属性も is_error・structured_content とスネークケースになる
  • pip と python3 の向き先がずれることがあるので、venv の python を絶対パスで指定する
  • つなぐ前に公式クライアントで確かめておくと、「サーバーが悪いのか、つなぎ方が悪いのか」を切り分けられる

追記(2026-11-02): dict は型を付けると structured_content に入る

コメントで「ドキュメントでは、素の dict は dict[str, Any] として読まれ、{"result"} で包まずに structured_content に入るとある。dict[str, int] や TypedDict ではどうか」とご指摘をいただき、mcp 2.3.0 で戻り値の型だけを変えて試しました。

class CountTD(TypedDict):
    with_space: int
    without_space: int

@mcp.tool()
def bare_dict(text: str) -> dict: ...
@mcp.tool()
def typed_dict_str_int(text: str) -> dict[str, int]: ...
@mcp.tool()
def typed_dict_cls(text: str) -> CountTD: ...
bare_dict          -> structured_content: None                             outputSchema: None
typed_dict_str_int -> structured_content: {'with_space': 3, 'without_space': 1}  outputSchema: {'type': 'object', 'additionalProperties': {'type': 'integer'}, ...}
typed_dict_cls     -> structured_content: {'with_space': 3, 'without_space': 1}  outputSchema: {'properties': {'with_space': ..., 'without_space': ...}, 'required': [...], ...}
  • dict[str, int]・TypedDict(と pydantic の BaseModel)にすると、{"result"} で包まれずに、そのまま structured_content に入りました
  • 包まずに入るのは「キーが str の dict」です。SDK のソース(func_metadata.py)でも、dict[str, X] はそのまま、キーが str 以外の dict は {"result": ...} で包む分岐になっていました
  • 型を付けない dict は、2.3.0 では出力の形(outputSchema)が作られず、structured_content は None のままでした。本文の5章の結果はこのためです。ソースを見ると、素の dict は「型の情報を持たないクラス」の分岐に入り、構造化の対象から外れていました。ドキュメントの「素の dict も dict[str, Any] として扱う」という説明とは、このバージョンでは食い違っています(ドキュメントの版と SDK の版の違いかもしれませんが、確かめていません)
  • 型なしの dict に @mcp.tool(structured_output=True) を付けると、起動の時点で InvalidSignature: ... return type <class 'dict'> is not serializable for structured output で止まりました

本文の count_chars も、-> dict[str, int] にしておくのがよさそうです。content の text(JSON 文字列)は、どの型でも同じように入っていました。

1
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?