技術者向け
サービスの仕組み
MathTrail は、ご家庭ですでに使っているチャットと、保護者の Google ドライブとの間に立っています。問題はすべてチャットのモデルが書き、その問題を子どもに届けるかどうかはサービスが判断します。MathTrail は単一のプログラムです。自前のモデルは呼び出さず、リクエストとリクエストの間には何も保持しません。
- Cloud Run 上の単一の Go バイナリ
- ステートレス
- データベースなし
- 自前の LLM 呼び出しなし
- MCP 2026-07-28 · MCP Apps
チャットクライアント1 · クライアント
- Claude
- ChatGPT
- ほかの MCP Apps ホスト
チャットのモデルが問題を書き、カードがそれを子どもに見せます。
MCP · HTTPS 経由のツール呼び出し
Google Cloud 上の MathTrail2 · ランタイム
Cloud Run · 単一の Go バイナリ · ステートレス
コア:純粋な計算
- ルール
- レーティング
- チェック
- ソルバー
- コンテンツ
そのまわり:OAuth、MCP サーバー、カード、封印、制限。
Drive API v3 · 保護者自身のトークンで
Google ドライブ3 · ストレージ
MathTrail/mathtrail-profile.json
子どもごとにファイルを1つ、保護者自身のドライブに置きます。長く保存されるのは、これだけです。
01
構成図
だれがだれとやりとりするのか。子どもも大人も MathTrail に直接アクセスすることはなく、やりとりの相手はチャットホストです。中央のブロックにあるものはすべて単一のプロセスの一部で、別々のサービスではありません。好きな構成要素を選ぶと、その役割とつながり先が表示されます。
02
呼び出しごとに見るレッスン
モデルとカードは同じツールを呼び出し、信頼できる唯一の情報源、つまり保護者のドライブにあるプロフィールのファイルを土台にしています。レッスンを続けるために、両者が互いに何かを伝え合う必要はありません。
問題
- 大人
- カード
- ホストのモデル
- MathTrail
- ドライブ
1大人 → ホストのモデル: 「新しい問題をお願いします」
2ホストのモデル → MathTrail: next_task
呼び出しが始まるとすぐ、ホストがカードを表示:「次の問題を用意しています…」
制限 · ルールがテーマ、レベル、難易度を選ぶ
3MathTrail → ドライブ: 進行中のリクエストを書きこむ
4MathTrail → ホストのモデル: リクエストは進行中:パッケージを取得する
5ホストのモデル → MathTrail: get_package
6MathTrail → ホストのモデル: 指示書 · お手本問題3問 · ひっかけ · テンプレート · 図の例
モデルが問題、Starlark のソルバー、セルフチェックを書く
7ホストのモデル → MathTrail: submit_task(最大3回)
チェック · ソルバーが正解の選択肢をちょうど1つ見つけること
8MathTrail → ドライブ: 問題が現在の問題になり、答えは封印される
9カード → MathTrail: read_task(カードが4秒ごとに問い合わせる)
10MathTrail → カード: 問題文 · 図 · 選択肢5つ · ヒント(答えはなし)
どの呼び出しもドライブからプロフィールを読みますが、その読みこみは省いています。待機中のカードがパッケージを見ることはありません。パッケージのお手本問題には答えがついているからです。子どもが解いている間に、モデルは prepare_task で次の問題を先に書いておきます。次の問題を求められると、next_task がそれをすぐ新しいカードに出すので、この待ち時間はありません。
答え
- 大人
- 子ども
- カード
- ホストのモデル
- MathTrail
- ドライブ
1子ども → カード: Cを押す
2カード → MathTrail: submit_answer(チャットのターンは使わない)
最初の答えだけ · ここで封印を開ける · θとδは正誤だけで動く
3MathTrail → ドライブ: レーティングと履歴を書きこむ
4MathTrail → カード: 正誤 · Cの裏のひっかけ · 解き方の図 · 手順 · レーティングの前 → 後
カードが、問題のあった場所で「こたえのふりかえり」に変わる
5カード → ホストのモデル: ui/update-model-context(1行。大人の次のメッセージと一緒に読まれる)
6大人 → ホストのモデル: 「どうして柱が1本多いのですか?」
7ホストのモデル → 大人: まさにこの問題について答え、カードの内容を補う
ボタンを押すと、何かが説明される前に答えが記録され、カードはすぐに「こたえのふりかえり」に変わります。チャットのターンは使われず、モデルは何も描画しません。モデルはカードからの1行を大人の次のメッセージと一緒に読み、カードの内容をくり返さずに、問題についての質問に答えます。ホストがモデルに送るこの1行が失われても、モデルは次の呼び出しで追いつきます。
ログイン
- 保護者のブラウザ
- ホスト
- MathTrail
- ドライブ
1ホスト → MathTrail: POST /mcp(トークンなし)
2MathTrail → ホスト: 401 · メタデータの場所
3ホスト → 保護者のブラウザ: /oauth/authorize を開く · PKCE S256
4保護者のブラウザ → MathTrail: authorize
独自の同意画面:どのクライアントか、保護者をどこへ戻すか
5保護者のブラウザ → Google: Google でログイン · drive.file のみ
6Google → 保護者のブラウザ: Google のコードを持って戻る
7保護者のブラウザ → MathTrail: /oauth/callback
8MathTrail → Google: コードを交換
9MathTrail → ドライブ: /about(だれのドライブか?)
ユーザー ID = ドライブのアカウントの HMAC · Google のトークンを自前の認可コードに封印
10MathTrail → 保護者のブラウザ: 302 · code = mt1.c.…
11保護者のブラウザ → ホスト: コードを返す
12ホスト → MathTrail: /oauth/token · コード + コード検証子
13MathTrail → ホスト: アクセストークン15分 · 更新トークン30日
保護者のログインは一度だけです。サービスはこのやりとりを何も保持しません。リクエストは封印された state に、Google のトークンは封印されたコードに入って運ばれます。
ステートマシンとして見た現在の問題
「一度だけ」のルールはどれもプロフィールのフィールドで、同じ読みこみ・計算・書きこみの中でチェックされます。ほかに何かを覚えているものはありません。
nonenext_task指示書の準備完了requestedsubmit_task ✓封印して出題issuedsubmit_answer一度だけ記録answered
requested ↺却下、まだ試行できる(リクエストごとに3回まで)requested → none却下3回、またはリクエストの開始から15分。その日の問題数は減らず、却下3回はその日の失敗したリクエストとして数えるissued → requested答える前に「次の問題」:スキップとして記録し、レーティングはそのままanswered ↺その後の答え(別のタブやモデルからのもの)には最初の結果を返し、何も書きこまないanswered → requested次の問題は新しいカードに。答えた問題は、もう現在の問題ではないanswered → issued次の問題が先に書かれていた:next_task がそれをすぐ新しいカードに出し、requested を飛ばすissued → none封印をもう開けられない(鍵が2回ローテーションした)。何も記録しない
03
状態の置き場所
状態を持つサーバーならデータベースに入れておくものは、すべてほかのだれかが持っています。チャットが保持する封印されたトークン、保護者のブラウザの Cookie、そして保護者自身のドライブです。サービスを再起動しても、2つ目のコピーを起動しても、何も失われません。
MathTrail 自体
なし
アカウントも、セッションも、登録も、Google のトークンもありません。リクエストはそれぞれ、必要なものをすべて自分で運びます。
チャットのトークンストア
封印されたトークン。ホストには中身が見えない
- アクセストークン
- 15分
- 更新トークン
- 30日、最長90日
中には、Google のトークン、制限に使う中身の見えないユーザー ID、保護者がログインした国が封印されています。
保護者のブラウザ
2つの Cookie、セッションなし
- 承認済みのクライアント
- 180日
- ログインの保護(CSRF)
- 10分
Cookie を失っても、同意画面がもう一度出るか、ログインし直すだけです。
保護者のドライブ
長く保存されるのはここだけ
mathtrail-profile.json- 削除されるまで
プロフィール、レーティング、現在の問題(答えは封印済み)。保護者はファイルを開くことができ、ドライブには版の履歴が残ります。
Secret Manager
インスタンスの起動時に一度だけ読みこむ
- 封印の鍵ローテーション後も、前の鍵は引き続き受け付ける
- 90日
- Google のクライアントシークレット
- 固定
インスタンスのメモリ
キャッシュのみ:再起動で消えても、何も壊れない
- リクエスト頻度のカウンター
- 数分
- プロフィールのファイルの場所
- 10分
- クライアントの文書
- 5分〜24時間
v1 であえて入れなかったもの
- データベース
- キュー
- セッションストア
- 共有キャッシュ
- 作り置きの問題バンク
- LLM API クライアント
- REST と OpenAPI
04
プロフィールのファイル
子ども1人に JSON ファイル1つ。サーバー上にある何かの真実のキャッシュではなく、真実そのものです。どのブロックにも上限があり、書きこむたびに守られるので、ファイルが際限なく大きくなることはありません。ふつうのファイルは目標の範囲に収まり、上限に近いファイルは目標を超えます。過去の問題は、本文ではなくフィンガープリントとして残ります。
ファイルのサイズ
約61KB / 約79KB
| ブロック | 中身 | 書きこみ元 | サイズ | |
|---|---|---|---|---|
| フィンガープリント | 出題した問題のスケッチを最大200件。ほぼ重複のチェック用で、本文・テーマ・日付は含まない | next_tasksubmit_task | 27KB / 29KB | |
| レーティングの履歴 | 最近の答えそれぞれの直前のθとテーマのδ、そして答えのあった直近7日の各日の始まりのθと全テーマ | submit_answer | 14KB / 15KB | |
| レーティングとテーマ | θと子どもの出発点。テーマごと:δ、答え、かかったひっかけ、マスターとそのレベル | next_tasksubmit_tasksubmit_answer | 8KB / 14KB | |
| 最近の答え | 直近20件の答えとスキップ。ただし、ルールが読む5件の答えを下回ることはない | next_tasksubmit_answer | 4KB / 5KB | |
| 現在の問題(封印) | 答え、間違った選択肢それぞれの裏にあるひっかけ、解き方、ソルバー。これらをまとめた、中身の見えない単一の文字列 | next_tasksubmit_tasksubmit_answer | 4KB / 8KB | |
| 現在の問題(平文) | カードに表示されるもの:問題文、図、5つの選択肢、ヒント。答えたあとは、答えそのものも | next_tasksubmit_tasksubmit_answer | 2KB / 4KB | |
| 進行中のリクエスト | 問題を書いている間:その ID、指示書、使った試行回数、テーマを選んだのはだれか | next_taskprepare_tasksubmit_task | 1.5KB / 2KB | |
| サービスと子ども | スキーマのバージョン、ランダムな UUID、リビジョン。ニックネーム、学年、好きなこと、メモ。その日のカウンター | save_profileedit_profile書きこみのたび | 0.6KB / 1.5KB |
先に書かれた問題は現在の問題の隣に置かれ、ほぼ同じ大きさです。ファイルに含まれないもの:過去の問題の本文、子どもが答えるまでの平文の答え、保護者がだれかわかるもの、そしてランク(ランクは表示するときに計算します)。ドライブには条件付きの書きこみがないため、保存の前にファイルをもう一度読み、変わっていれば書きこみを拒否します。2つのタブが黙って互いを上書きすることはありません。
05
封印されるものと、その期間
1つのしくみが2つの役目を果たします。ログインはチャットが持つトークンの中に、問題の答え(現在の問題のものと、先に書かれた問題のもの)はプロフィールの中に封印します。封印にはそれぞれ用途が書きこまれているので、アクセストークンが更新トークンになりすますことはできず、封印された答えがほかの何かになりすますこともできません。
トークンの分解
mt1.a.kQ7fWx.5rJ0Yb2mQ8vK1nT4pXc9…
- mt1
- 形式のバージョン
- 用途
- c コード · a アクセス · r 更新 · d クライアント · s state · k 同意 · t 問題の答え
- 鍵 ID
- 鍵そのものから導くため、ローテーションしても気づかれない
- 暗号文
- XChaCha20-Poly1305、192ビットのランダムなノンス
JWT ではありません。サービス以外にこれを読むものはなく、中身の見えない文字列からクレームがログに漏れることもありません。
有効期間(対数目盛り)
- 認可コード
60秒 - ログイン処理中
10分 - アクセストークン
15分 - 更新トークン
30日(スライド式、最長90日) - 封印の鍵
90日ごとにローテーション - 承認済みのクライアント
180日
状態を持たないサーバーはコードを使用済みにできないため、コードの有効時間は短くしています。ローテーション後も前の鍵を受け付けるので、鍵が変わってもトークンや封印された答えが失われることはありません。