Xでシェア

Writing / MCP

N° 11

Python公式SDKで最小のMCPサーバーを作る:create_task Toolをstdioで公開する

Python公式MCP SDKのFastMCPを使い、入力検証・JSON保存・構造化出力・ToolErrorを備えたcreate_task Toolを実装します。MCP InspectorでSchemaを確認し、stdio経由でAIクライアントから呼び出すところまで解説します。

このガイドに含まれます

MCP完全ガイド

この記事を読むとできるようになること

  • 公式Python SDKで最小のMCPサーバーを実装できる
  • create_task Toolをstdioで公開し、呼び出しを確認できる

はじめに: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、初期ステータス、作成日時を補います。

項目

必須

制約

title

string

はい

1〜100文字、空白のみは禁止

description

string / null

いいえ

最大500文字

priority

string

いいえ

lowmediumhigh。既定値はmedium

保存されたタスクは次のような形になります。

json
{
  "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バージョン

この記事では次の環境を基準にします。

plaintext
Python: 3.13
MCP Python SDK: 1.28.1
Package manager: uv
Transport: stdio
Storage: local JSON file

2026年7月13日時点では、v1.xが安定版で、1.28.1が最新リリースです。PyPIのリリースページではPython 3.10以上が要件として示されています。v2はプレリリース段階で、公式リポジトリは安定版を2026年7月27日に予定しています。

記事とサンプルコードの再現性を優先し、依存関係は完全に固定します。

bash
uv add "mcp[cli]==1.28.1"

ライブラリとして長期間運用し、互換性のあるパッチ更新を受け取りたい場合はmcp>=1.28,<2も選択肢です。ただし、チュートリアルでは本文、ロックファイル、完成コードのバージョンを一致させた方が原因を切り分けやすくなります。

uvでプロジェクトを作成する

プロジェクトを初期化し、PythonとSDKを固定します。

bash
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]"が案内されています。

最終的な構成は次の程度で十分です。

plaintext
minimal-mcp-task-server/
├── server.py
├── data/
│   └── .gitkeep
├── tests/
│   └── test_server.py
├── .python-version
├── pyproject.toml
└── uv.lock

この記事では全体の流れを追いやすくするため、モデル、保存処理、Tool、エントリーポイントをserver.pyにまとめます。

FastMCPでサーバーを初期化する

最初のコードは2行です。

server.pypython
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として登録されます。

server.pypython
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はいつ使うべきか」を判断する材料になります。executedo_somethingよりcreate_taskの方が目的を推測しやすく、Create something.より「ローカルのタスクリストへ新しいタスクを追加する」という説明の方が誤選択を減らせます。

ただし、Tool名や説明だけで安全性を保証することはできません。ファイルを変更する副作用があるため、クライアント側でも実行内容を表示し、必要に応じてユーザーの承認を求める設計が適切です。

入力Schemaとバリデーション

FastMCPは関数シグネチャからinputSchemaを生成します。たとえば次の型は、概念的にはenumと既定値を持つJSON Schemaになります。

python
priority: Literal["low", "medium", "high"] = "medium"
json
{
  "type": "string",
  "enum": ["low", "medium", "high"],
  "default": "medium"
}

さらにAnnotatedとPydanticのFieldを使い、長さと説明をSchemaへ含めます。

server.pypython
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の引数は次のように変更します。

python
@mcp.tool()
def create_task(
    title: TaskTitle,
    description: TaskDescription | None = None,
    priority: Literal["low", "medium", "high"] = "medium",
):
    ...

ここで重要なのは、すべての検証をSchemaだけに押し込まないことです。

検証の種類

実装場所

構造

titleが文字列か

型ヒント

単項目の制約

100文字以内か

Field

選択肢

優先度が3種類のいずれかか

Literal

業務ルール

空白だけのタイトルではないか

Tool本体

外部要因

JSONを書き込めるか

例外処理

min_length=1は空文字列を拒否しますが、" "は3文字なので通過します。そこで、Tool内部でstrip()した後の値を検証します。SchemaはAIが正しい引数を組み立てる手掛かりにもなりますが、信頼境界の外から来る入力として、サーバー側の検証を省略してはいけません。

JSONファイルへ保存する

作成結果をdata/tasks.jsonへ保存します。メモリ保存よりコードは少し増えますが、サーバー終了後も結果を確認でき、副作用を理解しやすくなります。

server.pypython
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モデルを使います。

server.pypython
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に対して検証されると説明されています。

エラーを通常の成功値として返す実装は避けます。

python
return {"success": False, "message": "Task title is invalid"}

この値はTool呼び出し自体の成功結果として扱われ得ます。予想可能な失敗にはToolErrorを送出します。

python
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ファイルにまとめます。

server.pypython
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で起動する

エントリーポイントは次の部分です。

python
def main() -> None:
    mcp.run(transport="stdio")

stdio transportでは、MCPクライアントがサーバープロセスを起動し、標準入力と標準出力でプロトコルメッセージを交換します。HTTPサーバーのように待受ポートを公開する必要はありません。

重要なのは、stdoutへ通常のログを出さないことです。

python
print("Task created")  # stdioでは避ける

stdoutはMCPの通信路なので、文字列が混ざるとプロトコルメッセージが壊れます。公式ガイドもstdioサーバーではstdoutへ書かず、stderrまたはloggingを使うよう案内しています。

python
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Task created")

Pythonのloggingは通常stderrへ出力します。設定を変更する場合も、stdio通信と同じstdoutへ向けないよう確認してください。

MCP Inspectorで確認する

AIクライアントへ登録する前にInspectorで単体確認します。

bash
uv run mcp dev server.py

Inspectorでは次を確認します。

  1. Tool一覧にcreate_taskが表示される
  2. docstringの説明が表示される
  3. titleが必須で、最大100文字になっている
  4. descriptionがnullableかつ任意になっている
  5. priorityに3つの候補とmediumの既定値がある
  6. 戻り値の出力SchemaにTaskの各項目がある

正常系には次の引数を使います。

json
{
  "title": "MCPの記事を書く",
  "description": "Python公式SDKを使った実装記事",
  "priority": "high"
}

続いて、priorityurgentを渡すSchemaエラー、101文字以上のタイトル、空白だけのタイトルを試します。前者2つは関数実行前の入力検証、最後の1つはTool内部の業務ルールです。この違いを確認できれば、型ヒントとTool本体の責任分担が理解できます。

AIクライアントから呼び出す

stdio対応クライアントには、サーバーを起動するコマンドと引数を登録します。設定形式はクライアントごとに異なりますが、概念的には次の形です。

json
{
  "mcpServers": {
    "task-manager": {
      "command": "uv",
      "args": [
        "--directory",
        "/absolute/path/to/minimal-mcp-task-server",
        "run",
        "python",
        "server.py"
      ]
    }
  }
}

GUIアプリからuvが見つからない場合は、commanduvの絶対パスを指定します。プロジェクトのパスにも絶対パスを使うと、起動時のカレントディレクトリに依存しません。設定後はクライアントを再起動し、Tool一覧を更新します。

試すプロンプトは次のとおりです。

plaintext
「MCPサーバーの記事を書く」というタスクを作成してください。
優先度はhighで、説明には「Python公式SDKを使った実装記事」と入れてください。

実行前にクライアントが表示するTool名と引数を確認して承認します。成功後はdata/tasks.jsonを開き、UUID、todo、作成日時を含むデータが追加されたことを確認します。

よくあるエラー

症状

主な原因

確認方法

Toolが表示されない

設定が未読込

設定形式を確認してクライアントを再起動する

サーバーが起動しない

GUIからuvが見えない

uvの絶対パスを指定する

接続直後に切断される

stdoutへログを出している

print()を削除しstderrへ変更する

モジュールが見つからない

作業ディレクトリが違う

--directoryと絶対パスを使う

JSONを書き込めない

権限またはパスの問題

dataの権限と保存先を確認する

Schemaに制約がない

型情報が不足している

AnnotatedFieldLiteralを確認する

エラーなのに成功扱い

通常値としてエラーを返している

想定内の失敗にはToolErrorを使う

記事と挙動が違う

SDKバージョンが違う

pyproject.tomluv.lockを確認する

最低限のテスト方針

完成リポジトリには、少なくとも次のケースを含めます。

  • 正常な入力でTaskが返り、JSONへ保存される
  • 省略した優先度がmediumになる
  • 空白だけのタイトルがToolErrorになる
  • 長すぎるタイトルが入力Schemaの検証で拒否される
  • 未定義の優先度が拒否される
  • 壊れたJSONや書き込み失敗がToolErrorになる

テストでは本番用のdata/tasks.jsonを使わず、一時ディレクトリへDATA_FILEを差し替えます。また、関数を直接呼ぶテストだけではMCP境界でのSchema検証を確認できないため、SDKのテスト用クライアントまたはInspectorによるTool呼び出しも組み合わせます。

まとめ

今回の実装では、FastMCPがプロトコル処理を担当し、@mcp.tool()がPython関数をToolとして公開しました。型ヒント、FieldLiteralは入力Schemaを作り、Pydanticモデルは構造化された成功結果を定義します。空白タイトルや保存失敗はToolErrorへ変換し、stdioのstdoutはプロトコル専用に保ちました。

この一本の流れを理解できれば、次はlist_tasksupdate_taskdelete_taskを追加して複数Tool間の名前・説明・Schemaを設計できます。その後にStreamable HTTP、認証、データベースへ進むと、MCP固有の責務と一般的なバックエンドの責務を分けて考えやすくなります。

次のステップ

MCPサーバーをAIクライアントに接続する方法:stdioの仕組みから設定・Tool実行まで解説

Filesystem MCP ServerをClaude Desktopへ接続するハンズオンを通して、command・args・envの意味、stdioによるプロセス間通信、initialize、tools/list、tools/callの流れを解説します。接続エラーをプロセス起動とMCP通信に分けて調査する方法も紹介します。

次の記事を読む