guidepost-mcp
by shogo-hs
README.md
# guidepost-mcp
案内の樹形図を MCP 経由で辿らせるサーバー。エージェントが回答のラベルを投げると、
**次に何を確認・案内すべきか**がルールベースで返る。端まで辿れば案内完了。
自己ループ型のエージェントは柔軟な代わりに、同じ問い合わせでも毎回違う道筋を通る。
返金・本人確認のような外してはいけない応対では、これが監査にもエスカレーション判断にも耐えない。
判断が要る場所を樹形図として外に固定し、**言葉にするところだけをエージェントに任せる**。
CS 専用ではない。樹形図の語彙をドメインに依存させていないので、手続きを順に進める仕事全般に使える。
```
顧客の発話 guidepost-mcp (MCP · 8127)
│ ┌──────────────────────────┐
┌───▼────────┐ values │ flows/*.yaml ← 起動時に │
│ エージェント ├──────────▶│ メモリ常駐(読むだけ) │
│ │◀──────────┤ engine = 純関数で遷移 │
└────────────┘ next │ SQLite = runs / steps │
│ 発話をラベルに └──────────┬───────────────┘
│ 落とすのはこちら側 │ 読み取り専用
▼ ┌───▼──────────┐
顧客へ返す │ Web UI (SSR) │ いま樹形図のどこにいるか
└───────────────┘
```
自然文 → ラベルの解釈はエージェント側が持つ。サーバーは受け取ったラベルを見て遷移するだけなので、
**LLM 呼び出しが増えない**。実測(Raspberry Pi 5)は遷移の計算が p95 0.006ms、
SQLite を含めて p95 0.76ms、HTTP の MCP を通したエンドツーエンドで p95 9.1ms。
音声応対 1 ターンの合計は約 2.5 秒なので、HTTP 込みでも 0.4% にあたる。
内訳と、その予算をどう引いたかは `docs/research/voice-agent-latency-budget.md`。
## 樹形図の書き方
`flows/<flow_id>.yaml` が 1 本の樹形図。ノードは 4 種類だけ。
| kind | 役割 | 分岐 |
|---|---|---|
| `ask` | 1 つの確認事項を聞き、答えのラベルで分岐する | する |
| `tell` | 1 つの案内を伝える | しない |
| `collect` | 独立した複数の項目を**順不同で**集める | しない |
| `end` | 終端。`outcome` を持つ | しない |
```yaml
id: payment_failed
title: 支払いが失敗した
entry: n_error_code
max_unmatched: 3 # 聞き直しの上限
max_branch_fanout: 3 # これより枝が多いと畳まない
branch_depth: 2 # 枝を辿って結末を探す深さ
on_unknown: broaden # 分からないと言われたとき。broaden / escalate
on_stuck: 原因が絞れないため、決済窓口の担当者に引き継ぐ
nodes:
- id: n_error_code
kind: ask
say: 決済画面に出ているエラーコードを確認する # 逐語原稿ではなく「何を伝えるか」
accepts: # ラベル → そのラベルに落とす条件
E01: カードが拒否された
E02: 残高不足・限度額超過
next:
E01: n_card_age
E02: n_balance
__other__: n_symptom # 想定外のラベルの逃がし先(任意)
__unknown__: n_generic # 分からないときの逃がし先(任意)
- id: n_identity
kind: collect
say: 本人確認に必要な情報を集める
on_unknown: escalate # 重要な手続きなので畳ませない
slots:
order_id:
ask: 注文番号を聞く
required: true # 埋まらないと進めない
phone: 登録の電話番号を聞く # 短い書き方(任意扱い)
next: n_verify
- id: n_resolved
kind: end
outcome: resolved
say: 解消したことを確認し、対応を締める
```
`say` は逐語原稿ではなく「何を伝えるか」の素材。言い回しはエージェントが場に合わせる。
**1 ノード = 1 つの確認事項か 1 つの案内**に保つ。
書いたら検証する。到達不能ノードや行き先の無いラベルは、書いた時点では動くので実行時まで気づけない。
```bash
uv run guidepost-mcp lint flows/ # CI でも回している
uv run guidepost-mcp show payment_failed --flows flows # 樹形図を木で表示
uv run guidepost-mcp drafts # 預かっている草案の一覧
uv run guidepost-mcp approve refund_request # 草案を検査し直して flows/ へ移す
uv run guidepost-mcp discard refund_request # 草案を捨てる
```
## MCP ツール
| ツール | 役割 |
|---|---|
| `guide_flows(category)` | 引数なしでカテゴリ一覧と件数、`category` 指定でその中のフロー一覧 |
| `guide_start(flow_id, subject, agent)` | run を開始。`run_id` + 最初のノード + **索引** |
| `guide_answer(run_id, choice, values, utterance)` | 回答を投げる。下記 5 状態のどれかが返る |
| `guide_state(run_id)` | 現在地・経路・収集済みの値。復帰と引き継ぎ用 |
| `guide_revise(run_id, to_node, clear)` | 戻る。訂正された値を落とす |
| `guide_close(run_id, outcome, reason)` | 端まで行かずに締める |
| `guide_draft(yaml)` | 樹形図を**草案として**預ける。lint の指摘が返る。応対には出ない |
### 入口の選定は2段階、樹形図の登録は草案止まり
樹形図が増えると全件返せなくなるので、`guide_flows()` はまずカテゴリ(`flows/categories.yaml`
に定義)と各件数を返し、`guide_flows(category=...)` でその中の一覧を返す。どのカテゴリにも
当てはまらないなら、無理に当てはめず樹形図を使わずに応対する。
`guide_draft` は樹形図を預かるだけで、`flows/` にも `guide_flows()` にも出ない。人が
`guidepost-mcp approve` で通して初めて正本になる。**樹形図の生成はこのサーバーの外の仕事**で、
応対の最中に生成することも想定していない(判断の理由は
[docs/adr/0011-draft-intake.md](docs/adr/0011-draft-intake.md))。
### 回答は蓄積され、埋まっている分だけ自動で前進する
顧客が「E01 が出てて、カードは 3 年前のやつで」と一度に話したら、答えを持っているのに
1 問ずつ聞き直すのは避けたい。`values` に分かっているものを全部渡すと、
埋まっている限り進み、**埋まっていない最初のノードで止まる**。
```
収集済み: {n_error_code: E01, n_card_age: over_1y}
n_error_code ──E01──▶ n_card_age ──over_1y──▶ n_expiry_check ──?──▶ …
✓ 聞かずに通過 ✓ 聞かずに通過 ▲ ここで止まる
```
飛ばしたノードは `skipped` で返る。先のノードの id をエージェントが知れるよう、
`guide_start` が**索引**(各ノード / スロットが何を聞くものかの 1 行)を 1 回だけ渡す。
`collect` の順不同も同じ仕組み。どの順に埋めても、揃った時点で通過する。
### 「分からない」で止まらない
問い合わせてきた人が答えを持っていないことは普通にある。粘って聞き直しても出てこない。
```
guide_answer
├─ ラベルが accepts にある ──────────▶ advanced / completed
├─ accepts に無い ──────────────────▶ unmatched(聞き直す)
│ │ max_unmatched 回で下へ
└─ choice="__unknown__" ──────────┐ │
▼ ▼
next.__unknown__ があるか
├─ ある ─▶ advanced(逃がし先へ)
└─ 無い ─▶ on_unknown は
├─ escalate ─▶ stalled(有人へ)
└─ broaden ──▶ 枝を畳めるか
├─ できる ─▶ branched
└─ 無理 ───▶ stalled
```
`branched` は**枝を確定しないまま、各枝の結末を並べて案内する**状態。
「E01 でしたらカード会社へ、E02 でしたら残高を確認してください」を組む材料が返る。
全枝が合流するノードがあれば `common` に入るので「いずれの場合も最後に○○」と括れる。
返金・本人確認のように曖昧なまま案内すると害が出る手続きは、`on_unknown: escalate` を
書いて畳ませない。**畳めないノードは `lint` が名指しする**ので、そこにだけ手当てすればよい。
## 起動
```bash
uv sync
uv run uvicorn guidepost_mcp.web:app --host 127.0.0.1 --port 8127
uv run python scripts/mcp_smoke.py # 実プロトコルで 1 周辿る
uv run python scripts/mcp_smoke.py --parallel # 2 本の run を交互に進める
```
Web UI は `http://127.0.0.1:8127/`。進行中の run が一覧され、`/r/<run_id>` で
樹形図の上に現在地・通った経路・先読みで飛ばしたノード・枝を畳んだノードが色分けされる。
エージェント側からは HTTP の MCP として繋ぐ。
```python
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient(
{
"guidepost-mcp": {"url": "http://127.0.0.1:8127/mcp/", "transport": "streamable_http"},
}
)
tools = await client.get_tools()
```
## バージョン
版の正本は `pyproject.toml` の `version` 1つで、SemVer に従う(0.x のあいだは minor で
破壊的変更が入りうる)。変更の履歴は [`CHANGELOG.md`](CHANGELOG.md)。
何を変えたら破壊的変更かは [`docs/adr/0009-versioning.md`](docs/adr/0009-versioning.md) に
定義してある。要点は**樹形図の YAML スキーマと MCP ツールの契約**(ツール名・引数・
戻り値のキー・`status` の5値)で、`next` の指示文の文面は含めない。
`flows/*.yaml` の `version` は**応対の手順の版**で、このライブラリの版とは無関係。
## 設計の背景
なぜラベル解釈をエージェント側に置くのか、なぜ「分からない」で枝を畳むのか、
なぜ Web UI を読み取り専用にしたのかは、`docs/adr/` に理由つきで記録してある
(一覧は [`docs/adr/README.md`](docs/adr/README.md))。判断の材料になった調べ物は
[`docs/research/`](docs/research/) にある。
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessResponsive