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

특양(特養)의 부족순(충족순)으로 정렬한다

주요 지표는 요개호3 이상 인정자 100명당 특양 정원수(전국 24.6명·중앙값 26.7명). 정의와 출처는 kaigo_gap_analysis 쪽에 있다.

집계는 여기서 작성하지 않는다

데이터는 안4(kaigo_gap_analysis)가 export_web.py로 내보내고, 공개 대시보드가 배포하는 것과 동일한 insurers.json을 복사해서 사용한다. 여기서 나눗셈을 다시 작성하지 않는다.

지표의 정의를 두 곳에 두면, 에이전트의 답과 대시보드의 숫자가 어긋났을 때 어느 쪽이 올바른지 판정할 수 없게 된다. 안4의 dataset.py가 "정의는 한 곳"이라는 방침으로 작성되어 있으므로, 그것을 넘어서 지키고 있다. 갱신은 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엔(실측)

측정 완료

각 열의 차이가 하나의 요인만이 되도록 구성하고 있다.

비교

알 수 있는 것

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에는 사용할 수 없었다.

루프는 하나만 작성한다. 한쪽만 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는 두 도구를 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%여도, 도달하지 못한 이유가 제각각. 정답률이라는 하나의 숫자만 나란히 하면, 이 차이가 사라진다.

도구 쪽의 주석은 효과가 있었지만, 효과가 너무 컸다

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·이 데이터의 출처)

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