2
2

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サーバーの「取扱説明書」を書いてみた ― 新着物件検索を例に、Python SDK v2と仕様2026-07-28で変わったこと・ツール設計の勘どころ

2
Posted at

Smart Hub Setup Guide Illustration.png

家電を買うと、箱の中に分厚い取扱説明書が入っています。

正直に言うと、自分はあれをほとんど読みません。読むのは、何かがうまく動かなかったときだけです。そして、そういうときに限って「故障かな?と思ったら」のページに、ちょうど自分の症状が書いてあったりします。

MCP(Model Context Protocol)のサーバーを作っていて、ふと思いました。MCP サーバーにも、取扱説明書が必要なんじゃないか。しかも読者は2人いる。サーバーを使う人間と、サーバーを呼び出す AI です。

今日は、仕事で作っている「新着の賃貸物件を探す MCP サーバー」を小さくしたものを例に、その取扱説明書を書いてみます。家電の説明書の章立てにそって、安全上の注意から、故障かな?と思ったら、仕様、保証まで。

ついでに、2026年7月に MCP の仕様と Python SDK が大きく変わったので、その話も「仕様」の章に入れておきます。ネットにある MCP サーバーの作り方の記事の多くは、今の SDK ではそのままでは動きません。 これから作る人は、ぜひそこだけでも読んでいってください。


はじめに:この製品について

この MCP サーバーは、AI アシスタント(Claude Code や Claude Desktop など)から、こんなふうに話しかけるためのものです。

中野駅から徒歩10分以内で、管理費込み15万円以内の新着物件、ある?

不動産会社のサイト制作や、新着物件の通知システムを作っていると、「社内の人が、条件を言うだけで物件を探せたら便利なのに」という相談をよく受けます。物件のデータベースはすでにあるので、あとは AI との間をつなぐ「差し込み口」があればいい。MCP は、その差し込み口の規格です。

USB の規格が決まっているおかげで、どのメーカーのマウスでもどのパソコンにも挿せるように、MCP の規格に沿ってサーバーを作れば、Claude でも、Cursor でも、VS Code でも、同じサーバーを使えます。

安全上のご注意

家電の説明書と同じく、いちばん大事なことを最初に書きます。

警告 この表示を無視して、誤った取り扱いをすると、データの破損や情報漏えいにつながるおそれがあります。

  • ツールに任意の SQL を渡さないでください。 「SQL を受け取って実行するツール」を作ると、AI が DELETE や DROP を書いた瞬間に終わります。プロンプトインジェクションで、そう書かされることもあります。検索条件は、型の決まった引数として受け取ります
  • データベースは読み取り専用で開いてください。 コードの上で SELECT しか書いていなくても、接続そのものを読み取り専用にしておけば、万が一のときも書き込めません
  • ツールの注釈(annotations)を、安全装置だと思わないでください。 「このツールは読み取り専用です」という注釈は、クライアントへの「ヒント」です。守るかどうかはクライアント次第で、SDK のドキュメントにも「ヒントであって、セキュリティではない」と明記されています

注意 この表示を無視して、誤った取り扱いをすると、動作が不安定になるおそれがあります。

  • stdio で動かすときは、print() を使わないでください。 標準出力は、AI アプリとの通信そのものに使われています。デバッグのつもりの1行が、通信を壊します。ログは logging(標準エラー出力)に書きます
  • 個人情報を返さないでください。 物件の管理会社の担当者名や、入居者の情報などは、データベースにあっても結果に含めません。AI に渡した情報は、会話の履歴としてどこかに残ると考えておきます

各部の名称

MCP には、登場人物が3人います。

名称 役割 この製品では
ホスト AI が動いているアプリ Claude Code、Claude Desktop など
クライアント ホストの中で、サーバーとの通信を受け持つ部分 ホストに内蔵
サーバー AI に道具やデータを差し出す側 今回作る「新着物件」サーバー

サーバーが差し出せるものも、3種類あります。

  • ツール(tools):AI が呼び出す関数。今回の search_listings がこれです
  • リソース(resources):AI が読むデータ
  • プロンプト(prompts):よく使う指示のひな形

今回の製品は、ツール1つだけです。これは手抜きではなく、意図した設計です。理由は「お手入れ」の章で書きます。

本体:サーバーのコード

構成は2つのファイルに分けています。

  • listings.py:検索のロジック本体。MCP には一切依存しない
  • server.py:MCP の差し込み口。listings.py を呼ぶだけ

こう分けておくと、検索ロジックは MCP なしでふつうにテストできます。家電でいえば、中身の基板と、外側のコンセントを別々に作って、基板だけ先に検査しておくイメージです。

listings.py
"""新着物件の検索ロジック。MCP には依存しないので、単体でテストできる。"""
import difflib
import sqlite3
import unicodedata
from datetime import date, timedelta


class UnknownStation(ValueError):
    pass


def normalize_station(name: str) -> str:
    name = unicodedata.normalize("NFKC", name).strip()
    return name.removesuffix("駅")


def search(db: sqlite3.Connection, station: str, max_walk_min: int, max_rent: int,
           layout: str | None, days: int, limit: int, today: date) -> dict:
    station = normalize_station(station)
    known = [r[0] for r in db.execute("SELECT DISTINCT station FROM listings")]
    if station not in known:
        hint = difflib.get_close_matches(station, known, n=3, cutoff=0.5)
        raise UnknownStation(
            f"「{station}」という駅の物件は登録されていません。"
            + (f"もしかして: {'、'.join(hint)}" if hint else "駅名の表記を確認してください。"))

    where = ["station = ?", "walk_min <= ?", "rent + mgmt_fee <= ?", "listed_on >= ?"]
    args: list = [station, max_walk_min, max_rent, (today - timedelta(days=days)).isoformat()]
    if layout:
        where.append("layout = ?")
        args.append(layout)
    sql = " AND ".join(where)
    total = db.execute(f"SELECT COUNT(*) FROM listings WHERE {sql}", args).fetchone()[0]
    rows = db.execute(
        f"SELECT id, name, walk_min, layout, rent, mgmt_fee, listed_on FROM listings "
        f"WHERE {sql} ORDER BY listed_on DESC, rent + mgmt_fee, id LIMIT ?", [*args, limit]).fetchall()
    items = [{"id": r[0], "name": r[1], "walk_min": r[2], "layout": r[3],
              "monthly_total": r[4] + r[5], "listed_on": r[6]} for r in rows]
    note = "" if total <= limit else f"ほかに {total - limit} 件あります。条件を絞るか limit を増やしてください。"
    return {"station": station, "total": total, "items": items, "note": note}

f"... WHERE {sql}" の部分で一瞬ドキッとした人もいるかもしれません。ここに入るのは、コードの中で固定した条件の文字列だけです。AI から受け取った値は、すべて ? のプレースホルダーで渡しています。

server.py
"""新着物件を、AI から安全に探すための MCP サーバー(読み取り専用)。"""
import sqlite3
from datetime import date
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

import listings

DB_PATH = "file:listings.db?mode=ro"  # 読み取り専用で開く
mcp = MCPServer("新着物件")


class Listing(BaseModel):
    id: str
    name: str
    walk_min: int = Field(description="最寄り駅からの徒歩分数")
    layout: str
    monthly_total: int = Field(description="家賃と管理費の合計(円/月)")
    listed_on: str = Field(description="掲載日(YYYY-MM-DD)")


class SearchResult(BaseModel):
    station: str
    total: int = Field(description="条件に合う物件の総数")
    items: list[Listing]
    note: str = Field(description="件数が多いときの案内。空なら全件を返している")


@mcp.tool(
    title="新着物件を探す",
    annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False),
)
def search_listings(
    station: Annotated[str, Field(description="最寄り駅の名前。例: 中野(「駅」は付けても付けなくてもよい)")],
    max_walk_min: Annotated[int, Field(ge=1, le=30, description="駅から徒歩何分以内か")] = 10,
    max_rent: Annotated[int, Field(ge=10_000, le=1_000_000, description="家賃と管理費の合計の上限(円/月)")] = 150_000,
    layout: Annotated[Literal["1R", "1K", "1DK", "1LDK", "2DK", "2LDK", "3LDK"] | None,
                      Field(description="間取り。指定しなければすべて")] = None,
    days: Annotated[int, Field(ge=1, le=30, description="何日以内に掲載された物件か")] = 7,
    limit: Annotated[int, Field(ge=1, le=20, description="返す件数の上限")] = 5,
) -> SearchResult:
    """最寄り駅・徒歩分数・月々の支払い総額・間取りで、最近掲載された賃貸物件を探す。

    結果は新しい順。家賃は必ず管理費込みの総額で比べる。
    """
    db = sqlite3.connect(DB_PATH, uri=True)
    try:
        return SearchResult(**listings.search(
            db, station, max_walk_min, max_rent, layout, days, limit, date.today()))
    except listings.UnknownStation as e:
        raise ToolError(str(e)) from e
    finally:
        db.close()


if __name__ == "__main__":
    mcp.run()  # 引数なしは stdio。stdout はプロトコル専用なので print() しない

Python SDK の v2 では、型ヒントがそのまま入力の仕様(JSON Schema)になり、戻り値の型が出力の仕様になります。Field(ge=1, le=30) と書けば、範囲外の値は関数が呼ばれる前に SDK が弾いて、AI が読めるエラーとして返してくれます。

設置のしかた

Claude Code に登録する場合は、こうです。

claude mcp add listings -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

-- のあとが、サーバーを起動するコマンドです。ホストは、このコマンドでサーバーを子プロセスとして立ち上げて、標準入出力でやりとりします。ポートを開ける必要はありません。

パスは絶対パスで書いてください。ホストがどのディレクトリからサーバーを起動するかは、ホスト次第だからです。「自分の環境では動いたのに」の原因の上位常連です。

使いかた

設置が終わったら、Claude Code にふつうに話しかけるだけです。

中野駅から徒歩10分以内で、管理費込み15万円以内の新着物件、ある?

Claude は search_listings を、たとえばこんな引数で呼び出します。

{"station": "中野駅", "max_walk_min": 10, "max_rent": 150000}

手元のテストデータ(6件)で、検索ロジックを実行した結果がこれです。

中野駅 / 10分 / 15万: 1 ['A1'] ''

テストデータには、家賃14万2千円・管理費1万2千円の物件も入れてあります。家賃だけ見れば15万円以内ですが、管理費込みだと15万4千円なので、ちゃんと結果から外れています。

物件探しで一番もめるのは、たぶんここです。「15万円以内」と言った人は、たいてい月々に払う総額のことを言っています。だから、このツールは家賃を必ず管理費込みで比べるようにして、そのことをツールの説明(docstring)にも書いています。AI が「家賃」という言葉をどちらの意味で受け取るかに賭けるのではなく、ツールの側で意味を固定してしまうわけです。

お手入れのしかた(ツール設計の勘どころ)

MCP サーバーを長く気持ちよく使うための、設計のコツです。

ツールは少なく、1つの仕事を上手に

Unix の生みの親のひとり、ダグ・マキルロイが、Unix の哲学をこうまとめています。

Write programs that do one thing and do it well.
(1つのことをうまくやるプログラムを書け)

MCP のツールにも、そのまま当てはまります。

最初は、search_by_station、search_by_rent、search_by_layout、get_listing_detail……と、ツールをいくつも作りかけました。でも、ツールが増えるほど、AI は「どれを呼ぶか」で迷い、間違えます。ツールの説明は毎回 AI の文脈に入るので、ツールが多いこと自体がコストでもあります。過ぎたるは猶及ばざるが如し、です。

結局、条件を引数で組み合わせられるツール1つにまとめたら、呼び間違いがなくなりました。

結果は小さく、でも「全体の大きさ」は伝える

結果を全件返すと、AI の文脈があっという間に埋まります。かといって、黙って5件だけ返すと、AI は「物件は5件しかない」と思い込みます。

そこで、limit で件数を絞りつつ、total(全体の件数)と note(「ほかに2件あります。条件を絞るか limit を増やしてください」)を必ず返すようにしました。

limit=2: 4 ['A2', 'A1'] ほかに 2 件あります。条件を絞るか limit を増やしてください。

AI は、この一文を読んで、「ほかにも候補があります。駅からの距離で絞りますか?」とユーザーに聞き返してくれるようになります。

エラーは「次にどうすればいいか」まで書く

AI はエラーメッセージを読んで、次の行動を決めます。なので、エラーは人間向けの説明書の「故障かな?と思ったら」と同じように、症状と、次にやることの形で書きます。

中埜 -> 「中埜」という駅の物件は登録されていません。もしかして: 中野
新宿 -> 「新宿」という駅の物件は登録されていません。駅名の表記を確認してください。

Python SDK の v2 では、ここで ToolError を使うのがポイントです。ToolError のメッセージは AI に届きますが、それ以外の例外(たとえば ValueError をそのまま投げた場合)は、AI には「ツールの実行でエラーが起きました」としか伝わりません。詳しい内容はサーバーのログにだけ残ります。AI に読ませたいエラーは ToolError、そうでないものは普通の例外、と使い分けます。

ちなみに、もしかして候補を出す difflib のしきい値は、最初はもっと低くしていました。すると「新宿」に対して「もしかして: 中野新橋」と返すようになり、「新」の1文字しか合っていないのに自信満々で提案してくる、なかなか味わい深い挙動になっていました。テストで気づいて、しきい値を上げています。

故障かな?と思ったら

症状 考えられる原因 処置
ホストにサーバーが表示されない 起動コマンドのパスが相対パスになっている 絶対パスで登録し直す
しばらくすると接続が切れる どこかで print() している(stdio が壊れる) ログは logging で標準エラー出力へ
ImportError: cannot import name 'FastMCP' 古い記事のコード(SDK v1 の書き方)を使っている from mcp.server import MCPServer に書き換える(後述)
AI が「エラーが起きました」としか言わない ToolError 以外の例外を投げている AI に伝えたいエラーは ToolError で
AI が条件に合わない物件まで紹介してくる 結果が limit で切られていることを AI が知らない total と note を返す
「なかの」で検索すると見つからない ひらがなの読みに対応していない 駅の読み仮名の列を持たせて照合する(今後の改良点)

仕様

項目 内容
対応 SDK MCP Python SDK v2 系(mcp 2.3.0 時点の API)
対応プロトコル 2026-07-28(SDK が旧バージョンのクライアントとの互換も受け持つ)
通信方式 stdio
ツール 1(search_listings、読み取り専用)
動作確認 検索ロジック(listings.py)は Python 3.14 でテスト済み

2026年7月に変わったこと

ここは、これから MCP サーバーを作る人に、いちばん伝えたいところです。

① Python SDK v2 で、FastMCP が MCPServer に名前を変えました。

2026年7月28日に Python SDK の v2 がリリースされ、v1 の from mcp.server.fastmcp import FastMCP は、非推奨ではなく削除されました。新しい書き方は from mcp.server import MCPServer です。モジュールも mcp.server.fastmcp から mcp.server.mcpserver に変わっていて、ToolError の import 先もそちらになっています。

検索して出てくる MCP サーバーの作り方の記事は、まだ v1 の書き方のものがたくさんあります。コピーして ImportError が出たら、まずここを疑ってください。SDK の公式サイトに移行ガイドもあります。

② MCP の仕様(2026-07-28 版)が、ステートレスになりました。

同じ日に公開された仕様の改訂は、MCP が登場して以来の大きな変更です。

  • 最初の「あいさつ」(initialize のハンドシェイク)とセッションがなくなりました。 毎回のリクエストに、プロトコルのバージョンとクライアントの能力が載るようになり、HTTP で動かすサーバーは、どのサーバーのインスタンスが応答してもよくなりました。スケールさせやすくなったわけです
  • サーバーが追加の情報を必要とするときの仕組みが変わりました。 サーバーからクライアントに問い合わせを送るのではなく、「この情報が必要です」という結果を返して、クライアントに再リクエストしてもらう方式(Multi Round-Trip Requests)になりました
  • Roots、Sampling、Logging の3つの機能が非推奨になりました。 新しく作るサーバーでは使わないように、とされています。ログは stdio なら標準エラー出力へ、という案内です
  • tools/list は決まった順番でツールを返すべき(SHOULD)、という項目も入りました。クライアント側でキャッシュしやすくなり、LLM のプロンプトキャッシュも効きやすくなるからだそうです

今回のサーバーのような、stdio で動く小さなツールサーバーなら、SDK v2 がこれらの違いを吸収してくれるので、書くコードはほとんど変わりません。ただ、v1 の書き方の記事をそのまま信じると、入口の import でつまずきます。

保証とアフターサービス

家電の説明書の最後には、保証書が付いています。

MCP の仕様にも、似たものが今回から入りました。機能の廃止のルールです。仕様の機能は「有効」「非推奨」「削除」の3つの状態を持ち、非推奨になってから削除されるまで、最低12か月の猶予を置くことが決まりました。Roots や Sampling を使っているサーバーも、明日いきなり動かなくなるわけではありません。これは、作る側にとってはかなりありがたい「保証期間」です。

一方で、Python SDK の v1 から v2 への変更のように、ライブラリの側では古い書き方が一気に消えることもあります。仕様の保証と、ライブラリの保証は別物です。そこは、家電本体と付属のケーブルの保証期間が違うようなものだと思っておくのがよさそうです。

この記事のサーバーは、物件の検索という小さな例ですが、社内のデータベースや業務システムを AI につなぐときの考え方は同じです。読み取り専用で始めること、ツールは少なく、結果は小さく、エラーには次の一手を書くこと。差し込み口の設計で迷ったら、気軽に声をかけてください。

最後に、説明書を最後まで読んでくださった方へ。

この説明書の「故障かな?と思ったら」の表は、ほぼすべて、自分が実際に踏んだものです。説明書は、だいたい誰かの失敗からできています。

2
2
1

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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?