kaigo-gap
Enables AI agents to answer questions about Japanese long-term care supply and demand, such as whether a municipality has enough special nursing homes, using public data.
README
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 サーバー
「どの道具を使うか」を判断 呼ばれたらデータを返す
道具
| 道具 | 用途 |
|---|---|
get_national_baseline |
全国の基準値。個別の数字を評価する前提になる |
lookup_insurer |
市区町村名・保険者名で需給を引く |
rank_insurers |
特養の不足順(充足順)に並べる |
主指標は 要介護3以上の認定者100人あたりの特養定員数(全国24.6人・中央値26.7人)。 定義と出典は kaigo_gap_analysis 側にある。
集計をこちらで書いていない
データは案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 のパスを書くときは \ で区切ること("C:\projects\kaigo_mcp")。
JSON では \p が不正なエスケープになり、設定ファイルごと読めなくなる。
このリポジトリの .mcp.json は実際にそれで壊れていた。
エージェント
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秒の回があった)
関連
- kaigo_matching — 介護記録の構造化抽出(案1)
- kaigo_gap_analysis — 保険者別の需給ギャップ分析(案4・このデータの出所)
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.