Skip to main content
Glama

kaigo_mcp

日本の介護の需給データを、AIエージェントが使える道具として公開するMCPサーバー。

Claude Code や Claude Desktop から接続すると、「この市は特養が足りているのか」を 公開データに基づいて答えられるようになる。

ステータス: サーバー・エージェント・eval まで動作。 列A(ローカル7B)・列B(ローカル9B)・列D(Claude Haiku 4.5)を計測済み。

何をするものか

MCP(Model Context Protocol)は、LLMに道具を持たせるための規格。このリポジトリが作るのは 道具の側で、LLMそのものは含まない。サーバー単体ではAPIを一切呼ばず、課金も発生しない。

[考える側]                    [このリポジトリ]
Claude Code / 自作エージェント  ←stdio→  kaigo-gap サーバー
「どの道具を使うか」を判断              呼ばれたらデータを返す

Related MCP server: Japan Data MCP

道具

道具

用途

get_national_baseline

全国の基準値。個別の数字を評価する前提になる

lookup_insurer

市区町村名・保険者名で需給を引く

rank_insurers

特養の不足順(充足順)に並べる

search_regulations

介護保険法と特養の運営基準の条文を、出典つきで検索する

主指標は 要介護3以上の認定者100人あたりの特養定員数(全国24.6人・中央値26.7人)。 定義と出典は kaigo_gap_analysis 側にある。

制度文書の検索(search_regulations)

対象は介護保険法と「指定介護老人福祉施設の人員、設備及び運営に関する基準」の本則(e-Gov法令API、 465条を項単位の1,508塊に分割)。通知・Q&A・他サービス(ショート・老健・小多機)の基準は含まない。

python scripts/fetch_laws.py          # 法令の取得(data/laws.json。git管理外)
ollama pull bge-m3                    # 埋め込みモデル(約1.2GB)
python scripts/eval_retrieval.py      # 初回のみ埋め込みを作る(CPUで約45分)→ 検索のeval

埋め込みが無い、または Ollama に繋がらないときは BM25 に退避し、応答の 検索方式 に出す。

検索の評価(retrieval-cases.json 56問、LLMを呼ばず0円。単位は条):

方式

r@1

r@3

r@5

MRR

BM25(文字2-gram)

0.38

0.46

0.57

0.438

埋め込み(bge-m3)

0.75

0.86

0.89

0.804

ハイブリッド(RRF)

0.55

0.73

0.82

0.643

質問を「条文の語そのまま/言い換え/現場の言い方」の3段階に分けると、差は現場の言い方で最も開く (r@1: BM25 0.17 → 埋め込み 0.66)。質問は自作で56問のため、1問で約1.8ポイント動く。 ハイブリッドが単体に負けたのは、弱いBM25が同じ重みで混ざるため。重みの調整は、調整用と本番用に 質問を分けるまでやらない。

集計をこちらで書いていない

データは案4(kaigo_gap_analysis)が export_web.py で書き出し、 公開ダッシュボード が配信しているのと 同じ insurers.json をコピーして使う。ここで割り算を書き直さない。

指標の定義を2か所に持つと、エージェントの答えとダッシュボードの数字が食い違ったとき、 どちらが正しいのか判定できなくなる。案4の dataset.py が「定義は1か所」という方針で 書かれているので、それを跨いで守っている。更新は python scripts/sync_data.py。

道具の設計で気をつけたこと

数字だけ返しても判断できない。 「12.4」を渡されてもLLMは高いか低いか分からないので、 全国順位と、道具の説明文に全国値を含めている。

同名自治体を1件に決め打ちしない。 「府中市」は東京都と広島県にある。 候補を両方返し、絞り込みは呼び出し側に委ねる。

特養定員0を「全国最下位」と読ませない。 該当が90保険者あり、全部が同率1位になる。 同率件数を必ず添え、さらに「小規模自治体では珍しくなく、住民は近隣自治体の施設を 利用していることが多い」という注記を付ける。これが無いと、 rank_insurers の結果から「新郷村は全国最悪の地域」という誤った結論が出る。

ドメイン知識を道具の応答に埋めておかないと、呼ぶ側のモデルを変えるたびに 同じ誤読が再発する。プロンプトではなく道具側に置くのはそのため。

使い方

pip install -r requirements.txt
python scripts/sync_data.py     # 案4からデータを取り込む
python scripts/smoke_test.py    # 通信の疎通確認

Claude Code から使うには、このディレクトリで起動すれば .mcp.json が読まれる。 他のクライアントに登録する場合の設定:

{
  "mcpServers": {
    "kaigo-gap": {
      "command": "python",
      "args": ["-m", "kaigo_mcp"],
      "cwd": "/path/to/kaigo_mcp"
    }
  }
}

Windows のパスは、区切りをバックスラッシュ2つで書くこと。

"cwd": "C:\\projects\\kaigo_mcp"

1つだと JSON の不正なエスケープ(\p)になり、設定ファイルごと読めなくなる。 このリポジトリの .mcp.json は実際にそれで壊れていて、 Claude Code から一度も読み込めていなかった。

エージェント

python -m kaigo_mcp.agent --list
python -m kaigo_mcp.agent "尼崎市は特養が足りてる?" --verbose

道具はMCPサーバー越しに呼ぶ(関数を直接importしない)。importで済ませると MCPを通していないことになり、「MCPサーバーを作った」という主張が検証されないため。

比較する列

列

モデル

場所

設定

1問コスト

状態

A

qwen2.5:7b

メイン機(CPU)

—

0円

計測済み

B

qwen3.5:9b

RTX 5050 8GB

num_ctx 4096(ollama既定)

0円

計測済み

B'

qwen3.5:9b

RTX 5050 8GB

num_ctx 8192

0円

計測済み

C

Qwen3.5-397B-A17B

DeepInfra

—

約0.8円

保留(登録にカードが要る可能性)

D

Claude Haiku 4.5

Anthropic

—

約1.4円(実測)

計測済み

各列の差が1つの要因だけになるように組んでいる。

比べる

分かること

A → B

ハードとモデル世代の効果(probe で切り分け済み: 品質は世代、速さはハード)

B → B'

設定だけの効果。モデルも重みも同じ

B' → C

モデルサイズだけの効果(同じ Qwen3.5 系で揃えてある)

C → D

モデル系統の差

B と B' を両方残しているのは、片方だけだと消える発見があるため。 同じモデル・同じ重みで、num_ctx を変えただけで正答率が 83% と 100% に割れる。 8k だけを載せれば「ローカル9Bは優秀」に見え、4k だけを載せれば 「ローカルは実用にならない」に見える。どちらも本当ではない。

列Cは保留中。当初 Qwen3-235B-A22B を指していたが、世代が3.0で列Bと揃わないうえ、 提供も終了していた。無料枠のカタログは入れ替わるので、使う前に /models で実在を確かめること。NVIDIA無料枠(C-alt-nvidia)も試したが、 1往復に168秒かかる回があり eval には使えなかった。

ループは1本しか書かない。 片方だけSDKのツールランナー、片方だけ手書きにすると、 列間の差がモデルの差なのかループ実装の差なのか分離できなくなる。 バックエンドは履歴の変換だけを担当する。

実測(列A・列B)

各6回・python scripts/probe_tool_calling.py で計測。

列A→列B ではハードとモデル世代が同時に変わるので、 切り分け用に 7B を GPU 機でも走らせた(真ん中の列)。

列A qwen2.5:7b (CPU)

qwen2.5:7b (GPU)

列B qwen3.5:9b (GPU)

道具呼び出し成功率(既定温度)

4/6 = 67%

5/6 = 83%

6/6 = 100%

道具呼び出し成功率(温度0)

6/6 = 100%

6/6 = 100%

6/6 = 100%

指示追従(温度0)

0/6 = 0%

0/6 = 0%

6/6 = 100%

1回あたり(温まった後)

7.6秒

0.4秒

3.2秒

初回(モデルロード込み)

102.6秒

36.1秒

19.2秒

温度0が要るのは 7B 固有だった

qwen2.5:7b は既定温度だと <tool_call> の開始タグが壊れ (olith pering のような数文字が先頭に付く)、ollama のパーサが認識できず 呼び出しが本文テキストに漏れる。閉じタグ </tool_call> だけが残るのが目印。温度0で解消。

qwen3.5:9b では既定温度でも 6/6。 この壊れ方はローカル実行一般の問題ではなく、 このモデルの世代・サイズに固有のものだった。

同じ 7B を GPU 機で走らせても既定温度で漏れ(5/6)、漏れ方の形まで同じだった (brtc {"name": "lookup_insurer", ...} </tool_call> — 開始タグの位置に数文字が居座り、 閉じタグだけが残る)。ハードを替えても直らない。 モデル側の癖である。

もっと大きい差は「指示に従うか」のほう

システムプロンプトは「答える前に get_national_baseline で全国の基準値を確認すること」 と指示している。9B は 6/6 で従い、7B は 0/6 で一度も従わなかった。 しかも 9B は2つの道具を1ステップで並列に呼ぶ。

7B が正しい答えを出せたのは、道具の説明文に埋めておいた全国値(24.6)を 拾ったからで、指示された手順は踏んでいない。安定性ではなく指示追従の差であり、 道具が増えるほど効いてくる。

効いたのはモデル世代で、GPUは速度だけだった

7B を GPU に載せても、品質の指標はどちらも動かなかった。 既定温度でのタグ崩れは残り(4/6 → 5/6、n=6 なのでゆらぎの範囲)、 温度0での指示追従は 0/6 のまま。GPU 機の 7B は既定温度のとき6回中2回だけ 基準値を引いたが、温度0では一度も引かない。

動いたのは秒数だけで、7.6秒 → 0.4秒(約19倍)。 つまり A→B の差のうち、成功率と指示追従はモデル世代の効果、 速さはハードの効果と読める。同じ表の中で分離できた。

ついでに、9B は 7B より8倍遅い(3.2秒 vs 0.4秒)。 同じ GPU でも、道具を2つ並列に呼び、指示に従うぶんだけ出力が長い。 速さと指示追従はここで真正面からトレードオフになっている。

秒数についての前回の訂正の、さらに裏取り

列Bの「既定 18.9秒 → 温度0 4.4秒」は温度の効果ではなく初回ロードだった。 改良版スクリプトで採り直すと、初回 19.2秒・以降 3.2秒(温度0では以降 3.9秒)で、 温まった後は温度で差がない。列Aの初回 102.6秒・以降 7.6秒と同じ構図で、 初回を混ぜた平均は温度差にもハード差にも見えてしまう。

通しで走らせると、賢さより先にコンテキストが落ちた

道具呼び出し単体ではなく、エージェントを1問最後まで回した結果。

「尼崎市は特養が足りてる?」

列A 7B (CPU)

列B 9B (4k)

列B' 9B (8k)

ステップ

2

2

3

秒

72.31

75〜89

21.6〜37.2

入力 / 出力トークン

1,648 / 171

1,985 / 3,121

3,176 / 601

答え

正しい

空(0/4回)

正しい(3/3回)

先に頭打ちになったのは VRAM ではなく、ollama の既定 num_ctx = 4096 だった。 9B が 6.6GB を占めるので KVキャッシュが苦しいと踏んでいたが、 そこへ届く前に既定のコンテキスト長で止まる。

止まり方が分かりにくい。2ステップ目が stop_reason='length' で、 プロンプト約1,150 + 出力2,946 = ちょうど 4,096。出力は3,121トークンあるのに本文は空。

引き金はモデル自身の誤った引数だった。尼崎市は兵庫県なのに lookup_insurer({'name': '尼崎市', 'pref': '大阪'}) を投げて該当0が返り、 そこから2,946トークン考え込んで、答えを書き始める前に使い切る。 自己回復に要るトークンがコンテキストを食い潰す。

num_ctx を 8192 にすると解消するが、今度は ollama ps が 12%/88% CPU/GPU を出す。 8GB には載りきらない。4k では答えが出ず、8k では GPU に載らない、 というのが RTX 5050 8GB で 9B を回すということだった。

難しい質問(青森県で3市町村を挙げて全国と比較・--max-steps 8)は 4k でも 3ステップ・41.06秒で完走した。 コンテキストを決めるのは質問の難しさではなく、回復の要否。

計測そのものの欠陥だった(修正済み)

直す前の loop.py は、道具呼び出しが無ければ stopped_by = "end" にしていた。 length で切られて本文が空でも "end" なので、 上の4回は記録上「2ステップで完走」と、列Aの成功と同じ形で残っていた。 空答えが完走と同じ顔をしていたら、列を並べても意味がない。

length または本文が空なら truncated を立てるようにした。 集計は RunRecord.answered を見る。CLI は理由を出して終了コード1を返す。

この手の欠陥は、失敗する列を実際に踏むまで見つからない。 列Aだけで回していたら気づけなかった。

そしてこの修正自体に穴が2つ残っていた。どちらも列C・Dを回した瞬間に効く。

  • 切れたことを表す値が各社で違う。 OpenAI互換は length、Anthropic は max_tokens。 length しか見ていなかったので、列D(Claude)では切れても検出できない。 列Bで空答えを完走と数えていたのと同じ壊れ方が、そのまま列Dで再発する。

  • 道具呼び出しの途中で切れる場合を見ていなかった。 判定が 「道具呼び出しが無いとき」の中にあったため。引数のJSONが欠けたまま次へ進むと、 道具が失敗した記録だけが残り、原因が「モデルが下手」に見えてしまう。

TRUNCATED_REASONS = {"length", "max_tokens"} を道具呼び出しの有無より先に判定する。 max_tokens を 8(道具の手前で切れる)と 40(道具呼び出しの途中で切れる)に絞って 実際に踏み、正常系が truncated に化けないことも併せて確認した。

eval の結果(列A・列B・列D)

6ケース・python scripts/eval_agent.py で計測。判定はすべてプログラムで書ける 条件のみ(LLM-as-judge は使わない)。列Bは3周(18件)。

列A 7B (CPU)

列B 9B (4k)

列B' 9B (8k)

列D Haiku 4.5

正答

5/6 = 83%

15/18 = 83%

18/18 = 100%

15/18 = 83%

完走

6/6 = 100%

15/18 = 83%

18/18 = 100%

18/18 = 100%

指示追従

2/6 = 33%

18/18 = 100%

18/18 = 100%

17/18 = 94%

1問の秒数(中央値)

72〜85秒

16.9秒

24.0秒

6.5〜8.5秒

コスト

0円

0円

0円

25円

num_ctx は速度の設定ではなく、正答率の設定だった

4k の失点3件はすべて amagasaki-basic の truncated/length。 仕組みは前節(通しで走らせると、賢さより先にコンテキストが落ちた) で見たものがそのまま eval にも出ただけで、新しい壊れ方ではない。

evalまで通して分かったのは、同じモデル・同じ重みで、 設定だけで正答率が 83% と 100% に割れること。 8k は回復ぶんの余地を与えているだけで、モデルは何も変わっていない。

「ローカルが Haiku を超えた」とは書けない

この6ケースでは列B'(8k)が上回っている(正答 18/18 対 15/18、 指示追従 18/18 対 17/18)。ただしそう読むには弱すぎる。

  • 6ケース×3周しかない。 母数が小さい

  • Haiku の失点は性能由来ではない。 下の「効きすぎた注記」の通り、 道具側に埋めた注記を読んで定員0の4件を除外した回が含まれる。 こちらの設計に起因する失点を、モデルの弱さとして数えるのは誤り

  • 秒数が3倍(24.0秒 対 6.5〜8.5秒)。同じ土俵ではない

言えるのは「この6問では、8kのローカル9Bで足りた」まで。 0円で手元から出ないという条件を考えれば、それ自体が十分な結果ではある。

正答率が同じでも中身は違う(列A)

列A・列B・列D はどれも正答 83% だが、内訳が違う。 列Aは道具の説明文に埋めた全国値を拾って偶然当てていて、 指示された基準値の確認をしていない(指示追従 2/6)。 列B・列Dは毎回引く。道具が増えるほどこの差が効く。

列B の失点は上記の truncated で、当てられなかったのではなく 答えを書き終えられなかった。同じ83%でも、届いていない理由が三者三様。 正答率という1つの数字だけを並べると、この違いが消える。

道具側の注記は効いたが、効きすぎた

rank_insurers の応答に「定員0は小規模自治体では珍しくない、単独で断じるな」と 埋めてある。列Dはこれを読んで、定員0の4件を答えから除外した(3周中1回)。 数字は実データどおりででっち上げではない。

誤読は防げたが、代わりに情報が隠れた。「断じるな」が「触れるな」と解釈された。 ドメイン知識を道具の応答に埋める判断は効くが、効きすぎることがある。

これから

  • 列Bは 4k / 8k の両方を載せると決めた(B と B')。設定だけで割れることが本題なので

  • 列C は保留。DeepInfra の登録にカードが要る可能性があるため

  • NVIDIA無料枠は eval には使えない(速度が保証されない。1往復168秒の回があった)

関連

この計測を書いた記事

num_ctx が正答率の設定でもあったこと、空答えを「完走」と数えていた計測の欠陥を、 このリポジトリの実測をもとに書いている。

同じ作者の関連プロジェクト

  • kaigo_gap_analysis — 保険者別の需給ギャップ分析(このデータの出所)

  • kaigo_matching — 介護記録から8項目を構造化して取り出すパイプライン

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to query Japanese public data (laws, corporations, statistics) from official government APIs, returning normalized English metadata with source attribution.
    1
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI assistants to search Japanese government subsidies and grants, including archived closed calls and recurring application periods based on historical data.
    10 npm
    1
    MIT