何に困ったか
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)、
mcp2.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 文字列)は、どの型でも同じように入っていました。