はじめに:MCPサーバーを作る側へ
MCPサーバーを設定してToolを使った経験があっても、その内部で何が起きているかは見えにくいものです。AIクライアントはToolをどのように発見し、引数を組み立て、不正な入力や実行エラーをどう扱うのでしょうか。
この記事では、Python公式MCP SDKに含まれるFastMCPを使い、create_taskというToolを1つだけ公開します。単なるHello Worldではなく、次の要素をすべて含む小さな実装です。
- 型ヒントから生成される入力Schema
- Schemaによる入力検証
- Tool内部の業務ルール検証
- JSONファイルへの保存
- Pydanticモデルによる構造化出力
ToolErrorによる実行エラー- stdio経由のAIクライアント接続
本記事で使うのは、独立したfastmcpパッケージではありません。mcpパッケージに含まれるmcp.server.fastmcp.FastMCPです。
今回作るMCPサーバー
公開するToolはcreate_taskだけです。入力はタイトル、説明、優先度の3項目で、サーバーがUUID、初期ステータス、作成日時を補います。
項目 | 型 | 必須 | 制約 |
|---|---|---|---|
| string | はい | 1〜100文字、空白のみは禁止 |
| string / null | いいえ | 最大500文字 |
| string | いいえ |
|
保存されたタスクは次のような形になります。
{
"id": "b4ac86da-2ca3-4a01-bf4a-ae85a37bc820",
"title": "MCPの記事を書く",
"description": "Python公式SDKを使った実装記事",
"priority": "high",
"status": "todo",
"created_at": "2026-07-13T21:30:00+09:00"
}自然言語からファイル保存までの境界を整理すると、次のようになります。

AIがPython関数を直接呼ぶわけではありません。MCPクライアントがプロトコル上のtools/listで定義を取得し、tools/callでサーバーへ実行を依頼します。Toolには名前、説明、inputSchemaなどが含まれることがMCP仕様で定義されています。
開発環境とSDKバージョン
この記事では次の環境を基準にします。
Python: 3.13
MCP Python SDK: 1.28.1
Package manager: uv
Transport: stdio
Storage: local JSON file2026年7月13日時点では、v1.xが安定版で、1.28.1が最新リリースです。PyPIのリリースページではPython 3.10以上が要件として示されています。v2はプレリリース段階で、公式リポジトリは安定版を2026年7月27日に予定しています。
記事とサンプルコードの再現性を優先し、依存関係は完全に固定します。
uv add "mcp[cli]==1.28.1"ライブラリとして長期間運用し、互換性のあるパッチ更新を受け取りたい場合はmcp>=1.28,<2も選択肢です。ただし、チュートリアルでは本文、ロックファイル、完成コードのバージョンを一致させた方が原因を切り分けやすくなります。
uvでプロジェクトを作成する
プロジェクトを初期化し、PythonとSDKを固定します。
uv init minimal-mcp-task-server
cd minimal-mcp-task-server
uv python pin 3.13
uv add "mcp[cli]==1.28.1"cli extraを付けると、Inspector連携などの開発用コマンドも導入されます。公式のv1.x READMEでもuv add "mcp[cli]"が案内されています。
最終的な構成は次の程度で十分です。
minimal-mcp-task-server/
├── server.py
├── data/
│ └── .gitkeep
├── tests/
│ └── test_server.py
├── .python-version
├── pyproject.toml
└── uv.lockこの記事では全体の流れを追いやすくするため、モデル、保存処理、Tool、エントリーポイントをserver.pyにまとめます。
FastMCPでサーバーを初期化する
最初のコードは2行です。
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("task-manager")task-managerはPythonの変数名ではなく、サーバーを識別する名前です。FastMCPはTool一覧の公開、引数のパース、型情報からのSchema生成、関数の実行、結果のMCP形式への変換などを担当します。
公式のサーバー構築ガイドも、FastMCPが型ヒントとdocstringからTool定義を生成すると説明しています。この時点では、起動できる器だけがあり、公開Toolはまだありません。
create_task Toolの最小実装
通常のPython関数に@mcp.tool()を付けると、MCPクライアントが発見・実行できるToolとして登録されます。
from typing import Literal
from uuid import uuid4
@mcp.tool()
def create_task(
title: str,
description: str | None = None,
priority: Literal["low", "medium", "high"] = "medium",
) -> dict[str, str | None]:
"""Create a new task and add it to the local task list.
Args:
title: Short title of the task.
description: Optional details about the task.
priority: Priority of the task.
"""
return {
"id": str(uuid4()),
"title": title,
"description": description,
"priority": priority,
"status": "todo",
}関数名とdocstringは、単なる開発者向け情報ではありません。AIが「このToolはいつ使うべきか」を判断する材料になります。executeやdo_somethingよりcreate_taskの方が目的を推測しやすく、Create something.より「ローカルのタスクリストへ新しいタスクを追加する」という説明の方が誤選択を減らせます。
ただし、Tool名や説明だけで安全性を保証することはできません。ファイルを変更する副作用があるため、クライアント側でも実行内容を表示し、必要に応じてユーザーの承認を求める設計が適切です。
入力Schemaとバリデーション
FastMCPは関数シグネチャからinputSchemaを生成します。たとえば次の型は、概念的にはenumと既定値を持つJSON Schemaになります。
priority: Literal["low", "medium", "high"] = "medium"{
"type": "string",
"enum": ["low", "medium", "high"],
"default": "medium"
}さらにAnnotatedとPydanticのFieldを使い、長さと説明をSchemaへ含めます。
from typing import Annotated, Literal
from pydantic import Field
TaskTitle = Annotated[
str,
Field(
min_length=1,
max_length=100,
description="Short title of the task",
),
]
TaskDescription = Annotated[
str,
Field(
max_length=500,
description="Optional details about the task",
),
]Toolの引数は次のように変更します。
@mcp.tool()
def create_task(
title: TaskTitle,
description: TaskDescription | None = None,
priority: Literal["low", "medium", "high"] = "medium",
):
...ここで重要なのは、すべての検証をSchemaだけに押し込まないことです。
検証の種類 | 例 | 実装場所 |
|---|---|---|
構造 |
| 型ヒント |
単項目の制約 | 100文字以内か |
|
選択肢 | 優先度が3種類のいずれかか |
|
業務ルール | 空白だけのタイトルではないか | Tool本体 |
外部要因 | JSONを書き込めるか | 例外処理 |
min_length=1は空文字列を拒否しますが、" "は3文字なので通過します。そこで、Tool内部でstrip()した後の値を検証します。SchemaはAIが正しい引数を組み立てる手掛かりにもなりますが、信頼境界の外から来る入力として、サーバー側の検証を省略してはいけません。
JSONファイルへ保存する
作成結果をdata/tasks.jsonへ保存します。メモリ保存よりコードは少し増えますが、サーバー終了後も結果を確認でき、副作用を理解しやすくなります。
import json
from pathlib import Path
from typing import Any
DATA_FILE = Path(__file__).parent / "data" / "tasks.json"
def load_tasks() -> list[dict[str, Any]]:
if not DATA_FILE.exists():
return []
with DATA_FILE.open(encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, list):
raise ValueError("Task data must be a JSON array.")
return data
def save_tasks(tasks: list[dict[str, Any]]) -> None:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with DATA_FILE.open("w", encoding="utf-8") as file:
json.dump(tasks, file, ensure_ascii=False, indent=2)パスをカレントディレクトリではなく__file__から組み立てることで、AIクライアントが別の作業ディレクトリから起動しても保存先が安定します。
このJSON実装は学習用です。複数プロセスによる同時更新、クラッシュ中の書き込み、大量データには対応していません。その段階ではファイルロック、アトミックな置換、またはデータベースを検討します。
構造化された成功結果とエラー
成功結果にはPydanticモデルを使います。
from datetime import datetime
from pydantic import BaseModel
class Task(BaseModel):
id: str
title: str
description: str | None
priority: Literal["low", "medium", "high"]
status: Literal["todo"]
created_at: datetime戻り値をTaskと宣言すると、FastMCPは対応する出力Schemaを生成し、構造化結果を検証します。公式SDKのBuilding Serversでは、Pydanticモデルを含む互換性のある戻り値型が構造化出力になり、生成されたSchemaに対して検証されると説明されています。
エラーを通常の成功値として返す実装は避けます。
return {"success": False, "message": "Task title is invalid"}この値はTool呼び出し自体の成功結果として扱われ得ます。予想可能な失敗にはToolErrorを送出します。
from mcp.server.fastmcp.exceptions import ToolError
if not title.strip():
raise ToolError("Task title must not be blank.")公式SDKではToolErrorのメッセージがisError: trueの結果としてクライアントへ返されます。MCP仕様も、入力検証、外部API失敗、業務ルール違反などをTool Execution Errorとして表現し、モデルが内容を読んで修正・再試行できるようにすることを想定しています。
完成したserver.py
ここまでの要素を1ファイルにまとめます。
import json
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any, Literal
from uuid import uuid4
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import BaseModel, Field
mcp = FastMCP("task-manager")
DATA_FILE = Path(__file__).parent / "data" / "tasks.json"
TaskTitle = Annotated[
str,
Field(
min_length=1,
max_length=100,
description="Short title of the task",
),
]
TaskDescription = Annotated[
str,
Field(
max_length=500,
description="Optional details about the task",
),
]
class Task(BaseModel):
id: str
title: str
description: str | None
priority: Literal["low", "medium", "high"]
status: Literal["todo"]
created_at: datetime
def load_tasks() -> list[dict[str, Any]]:
if not DATA_FILE.exists():
return []
with DATA_FILE.open(encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, list):
raise ValueError("Task data must be a JSON array.")
return data
def save_tasks(tasks: list[dict[str, Any]]) -> None:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with DATA_FILE.open("w", encoding="utf-8") as file:
json.dump(tasks, file, ensure_ascii=False, indent=2)
@mcp.tool()
def create_task(
title: TaskTitle,
description: TaskDescription | None = None,
priority: Literal["low", "medium", "high"] = "medium",
) -> Task:
"""Create a new task and save it to the local task list.
Args:
title: Short title of the task.
description: Optional details about the task.
priority: Priority of the task.
"""
normalized_title = title.strip()
if not normalized_title:
raise ToolError("Task title must not be blank.")
normalized_description = description.strip() if description else None
task = Task(
id=str(uuid4()),
title=normalized_title,
description=normalized_description or None,
priority=priority,
status="todo",
created_at=datetime.now().astimezone(),
)
try:
tasks = load_tasks()
tasks.append(task.model_dump(mode="json"))
save_tasks(tasks)
except (OSError, json.JSONDecodeError, ValueError) as exc:
raise ToolError("Failed to save the task.") from exc
return task
def main() -> None:
mcp.run(transport="stdio")
if __name__ == "__main__":
main()保存エラーでは内部例外の詳細をそのままモデルへ渡さず、操作可能なメッセージへ置き換えています。実運用では詳細をstderr側のログへ残し、クライアントには秘密情報やローカルパスを含めないようにします。
stdioで起動する
エントリーポイントは次の部分です。
def main() -> None:
mcp.run(transport="stdio")stdio transportでは、MCPクライアントがサーバープロセスを起動し、標準入力と標準出力でプロトコルメッセージを交換します。HTTPサーバーのように待受ポートを公開する必要はありません。
重要なのは、stdoutへ通常のログを出さないことです。
print("Task created") # stdioでは避けるstdoutはMCPの通信路なので、文字列が混ざるとプロトコルメッセージが壊れます。公式ガイドもstdioサーバーではstdoutへ書かず、stderrまたはloggingを使うよう案内しています。
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Task created")Pythonのloggingは通常stderrへ出力します。設定を変更する場合も、stdio通信と同じstdoutへ向けないよう確認してください。
MCP Inspectorで確認する
AIクライアントへ登録する前にInspectorで単体確認します。
uv run mcp dev server.pyInspectorでは次を確認します。
- Tool一覧に
create_taskが表示される - docstringの説明が表示される
titleが必須で、最大100文字になっているdescriptionがnullableかつ任意になっているpriorityに3つの候補とmediumの既定値がある- 戻り値の出力SchemaにTaskの各項目がある
正常系には次の引数を使います。
{
"title": "MCPの記事を書く",
"description": "Python公式SDKを使った実装記事",
"priority": "high"
}続いて、priorityへurgentを渡すSchemaエラー、101文字以上のタイトル、空白だけのタイトルを試します。前者2つは関数実行前の入力検証、最後の1つはTool内部の業務ルールです。この違いを確認できれば、型ヒントとTool本体の責任分担が理解できます。
AIクライアントから呼び出す
stdio対応クライアントには、サーバーを起動するコマンドと引数を登録します。設定形式はクライアントごとに異なりますが、概念的には次の形です。
{
"mcpServers": {
"task-manager": {
"command": "uv",
"args": [
"--directory",
"/absolute/path/to/minimal-mcp-task-server",
"run",
"python",
"server.py"
]
}
}
}GUIアプリからuvが見つからない場合は、commandにuvの絶対パスを指定します。プロジェクトのパスにも絶対パスを使うと、起動時のカレントディレクトリに依存しません。設定後はクライアントを再起動し、Tool一覧を更新します。
試すプロンプトは次のとおりです。
「MCPサーバーの記事を書く」というタスクを作成してください。
優先度はhighで、説明には「Python公式SDKを使った実装記事」と入れてください。実行前にクライアントが表示するTool名と引数を確認して承認します。成功後はdata/tasks.jsonを開き、UUID、todo、作成日時を含むデータが追加されたことを確認します。
よくあるエラー
症状 | 主な原因 | 確認方法 |
|---|---|---|
Toolが表示されない | 設定が未読込 | 設定形式を確認してクライアントを再起動する |
サーバーが起動しない | GUIから |
|
接続直後に切断される | stdoutへログを出している |
|
モジュールが見つからない | 作業ディレクトリが違う |
|
JSONを書き込めない | 権限またはパスの問題 |
|
Schemaに制約がない | 型情報が不足している |
|
エラーなのに成功扱い | 通常値としてエラーを返している | 想定内の失敗には |
記事と挙動が違う | SDKバージョンが違う |
|
最低限のテスト方針
完成リポジトリには、少なくとも次のケースを含めます。
- 正常な入力で
Taskが返り、JSONへ保存される - 省略した優先度が
mediumになる - 空白だけのタイトルが
ToolErrorになる - 長すぎるタイトルが入力Schemaの検証で拒否される
- 未定義の優先度が拒否される
- 壊れたJSONや書き込み失敗が
ToolErrorになる
テストでは本番用のdata/tasks.jsonを使わず、一時ディレクトリへDATA_FILEを差し替えます。また、関数を直接呼ぶテストだけではMCP境界でのSchema検証を確認できないため、SDKのテスト用クライアントまたはInspectorによるTool呼び出しも組み合わせます。
まとめ
今回の実装では、FastMCPがプロトコル処理を担当し、@mcp.tool()がPython関数をToolとして公開しました。型ヒント、Field、Literalは入力Schemaを作り、Pydanticモデルは構造化された成功結果を定義します。空白タイトルや保存失敗はToolErrorへ変換し、stdioのstdoutはプロトコル専用に保ちました。
この一本の流れを理解できれば、次はlist_tasks、update_task、delete_taskを追加して複数Tool間の名前・説明・Schemaを設計できます。その後にStreamable HTTP、認証、データベースへ進むと、MCP固有の責務と一般的なバックエンドの責務を分けて考えやすくなります。