Skip to content

feat(discord-bot): MCP にインデクサーの読み取りツールを追加する - #582

Merged
yu23ki14 merged 7 commits into
mainfrom
feat/mcp-subgraph-reads
Sep 16, 2026
Merged

yu23ki14 merged 7 commits into
mainfrom
feat/mcp-subgraph-reads

Conversation

@yu23ki14

@yu23ki14 yu23ki14 commented Sep 7, 2026

Copy link
Copy Markdown
Member

なにをしたか

Goldsky が持っている情報のうち、MCP に出ていたのは ThanksToken アドレス・クエストモジュール・Open クエストの id とタイトルだけでした。エージェントが「いま何 THX 持ってる?」「先月誰が誰に送った?」「レビュー待ちは?」に答えられない状態だったので、読み取りツールを 3 本 → 7 本にします。

ツール 変更
toban_workspace_info 全 7 種の hatId、6 モジュールのアドレス、creator/owner、開設日を追加。未インデックスなら indexed: false を返して落とさない
toban_member_status thxBalance(受け取った量)、thxSentTotal(送った累計)、roles[] を追加
toban_open_quests status 引数(Open / PendingReview / Completed / Cancelled)と limit。報酬シェア数・説明・承認数・提出者・各タイムスタンプ・パーマリンクを追加。discordUserId は任意化
toban_quest_detail 新設 — 提出の試行履歴(取り下げ/却下/承認)と承認者一覧、IPFS メタデータ URI
toban_thx_history 新設 — 送付履歴。data を UTF-8 に復元してメッセージも返す。direction / sinceDays / limit
toban_workspace_members 新設 — ロールシェア保有者とエスクロー中のシェア
toban_reward_distributions 新設 — 分配予定日・トークン・入金/実行/返却額

署名まわりは触っていません

書き込み系(toban_thx_propose / toban_quest_submit_propose)は変更なしです。「エージェントは提案しかできず、署名するのはクリック」「行為者は Discord 署名済み interaction からしか読まない」「performThx / performQuestSubmit がチェーンへの唯一の経路」という不変条件と、turnkey/policy.json には一切手を入れていません。追加したのは全部 GraphQL の読み取りです。

レビューで見てほしいところ

1. 読み取りクエリを chain.ts ではなく新しい src/mcp/queries.ts に置きました。
chain.ts は ABI と書き込みパスの resolver を持っていて、turnkey/policy.json と並べて監査できる大きさに保ちたいためです。共有するのは postGraphQL だけ(error handling を 1 箇所に保つため export しました)。

2. ワークスペーススコープをテストで担保しています。
全クエリが treeId を filter に載せ、全リストが first を取ります(上限 100)。トークンがギルドを、ギルドがワークスペースを固定するのが読み取り側の唯一の防壁なので、「実際にインデクサーに投げた変数」を assert するテストにしました(calls[0].variables.where を検証)。

3. 「送受信の両方」は where.or ではなく 2 エイリアスで引いています。
graph-node の or は兄弟フィルタと並ぶ形になり、その兄弟がワークスペーススコープなので優先順位を推測で書きたくありませんでした。1 リクエストのままで曖昧さがありません。

4. 単位を応答自体に載せました。 ここが一番議論の余地があると思います。

数量は互換性のない 3 単位が混在します。サブグラフは ERC-20 の decimals を持っていないので、raw は原理的に換算できません。

単位 項目 計算するとき
thx thxBalance amountThx など 換算済み。そのまま合計・比較してよい
shares amountShares shares ロールシェアの個数。割合の分母は 10000
raw totalDepositedRaw など ERC-20 の最小単位。換算しない。生の値のまま「単位未確定」と伝える

フィールド名の接尾辞に加えて、応答に units ブロック(項目 → 単位、および使った単位の注記)を付けています:

{
  "distributions": [{ "balances": [{ "totalDepositedRaw": "1000000" }] }],
  "units": {
    "fields": { "totalDepositedRaw": "raw" },
    "notes": { "raw": "ERC-20 の最小単位そのまま。小数桁をインデクサーが持っていないため未換算。…" }
  }
}

ドキュメントだけにしなかったのは、事故が起きるのは読んだ瞬間だからです。サードパーティのエージェントが読むのは JSON で、SKILL.md を配れるのは自分たちが運用する OpenClaw だけ。totalDepositedRaw: "1000000" を「100 万円ぶん」と読むのが一番ありそうな失敗で、USDC なら 1 USDC、18 桁のトークンなら 0.000000000001 と桁が 6 つ違います。

notes にはその応答で実際に使われている単位の分だけ入ります(クエスト一覧に thx の説明は付かない)。コンテキストを無駄に積まないためで、テストで担保しています。

テスト

68 → 91 全通過。新規 23 本は subgraph の fetch をスタブして、以下をカバーしています。

  • ワークスペーススコープが実際にクエリへ載っていること
  • 自作クエストが「完了報告できる一覧」から除外されること
  • 両方向マージの並び順と件数上限
  • 不正な status / sinceDays の拒否(=インデクサーを叩かずに止まること)
  • 逆引き(identity Worker)が落ちても履歴自体は返すこと
  • 制御文字の除去(mintFrom は public なので data は任意のバイト列)
  • *Thx / *Shares / *Raw で終わる全キーが units.fields に存在すること(新フィールドの登録漏れ検出)

デプロイ

手順に変更はありません。 新しい secret も binding も増えていないので、deploy:sepolia / deploy:base をそのまま実行できます。

ただし pkgs/openclaw/skills/toban/SKILL.md を更新しているので、Fly 側に反映するには pnpm openclaw push:config が必要です(イメージには焼き込まれていません)。

🤖 Generated with Claude Code

Goldsky が持っている情報のうち、MCP に出ていたのは ThanksToken アドレス・
クエストモジュール・Open クエストの id とタイトルだけだった。エージェントが
「いま何 THX 持ってる?」「先月誰が誰に送った?」「レビュー待ちは?」に答えられない。

読み取りツールを 3 本 → 7 本にする:

- toban_workspace_info   … hatId 7 種・モジュール 6 種・開設日を追加
- toban_member_status    … THX 残高と送付累計を追加(枠とは別物)
- toban_open_quests      … status 引数、報酬シェア・説明・承認数・URL を追加
- toban_quest_detail     … 新設。提出の試行履歴と承認者
- toban_thx_history      … 新設。送付履歴(data を UTF-8 に復元)
- toban_workspace_members… 新設。ロールシェアの保有とエスクロー
- toban_reward_distributions … 新設。分配の予定と実績

書き込み系(*_propose)は変更なし。確認ボタン経由でしか署名しない不変条件と、
Turnkey のポリシーには一切触れていない。

実装上の判断:

- 読み取りクエリは src/mcp/queries.ts に置いた。chain.ts は ABI と書き込み
  パスの resolver を持ち、turnkey/policy.json と並べて監査できる大きさに
  保ちたいため。共有するのは postGraphQL だけ。
- 全クエリが treeId を filter に載せ、全リストが first を取る(上限 100)。
  トークンがギルドを、ギルドがワークスペースを固定するのが読み取り側の
  唯一の防壁なので、テストで「実際に投げた変数」を検証している。
- 「送受信の両方」は where.or ではなく 2 エイリアスで引く。graph-node の or は
  兄弟フィルタと並ぶ形になり、その兄弟がワークスペーススコープなので優先順位を
  推測で書きたくない。1 リクエストのままで曖昧さがない。
- 数量は 3 つの互換性のない単位(THX / ロールシェア / ERC-20 の生の値)が
  混在する。サブグラフは ERC-20 の decimals を持っていないので換算できない。
  フィールド名の接尾辞に加えて、応答自体に units ブロック(項目 → 単位と、
  使った単位の注記)を載せた。事故が起きるのは読んだ瞬間であって、
  ドキュメントを読む瞬間ではないため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
toban Ready Ready Preview Sep 16, 2026 8:14am UTC

Request Review

yu23ki14 and others added 5 commits September 7, 2026 21:42
`POST /mcp` を `@toban/discord-bot` から `pkgs/extensions/mcp` へ移し、
Discord を確認チャネルのアダプタという位置に下げる設計。

決定事項:
- Write はエージェント自身が行わず、確認チャネルのアダプタに依頼するのみ。
  またぐのは POST /internal/propose 1 本で、Turnkey 資格情報 / actor /
  performThx / ConfirmPayload を触るコードは全部 discord-bot に残る
- read は他ワークスペースも読めてよい(オンチェーン由来で Goldsky が公開)。
  トークンの treeId は境界ではなく既定値
- ただし identity の解決(Discord <-> ウォレット)だけは home に限る。
  D1 にあり公開されていないので、越境させると他コミュニティの名簿が作れる
- トークンは tbn2.<treeId>.<tokenId>.<mac>。個別失効のため登録簿を持つ
- 発行は /$treeId/settings から。Privy のウォレットで認証し、
  Worker 側が Hats subgraph で hat 保有を検証する
- OAuth はやらない。OpenClaw は多数の代理で動くので per-user OAuth と
  モデルが合わず、セルフサービス発行に必要な部分は OAuth 無しで足りる
- 確認ボタンは残る。認証が代替できるのは「誰が actor か」だけで、
  「本人が今この取引を意図した」の証明は代替できない

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Privy の JWT 検証だと Worker に新しい secret と外部 JWKS 依存が増え、
スマートコントラクトウォレットを扱えない。リポジトリには既に
pkgs/extensions/identity/src/eip712/ + verify.ts のオフチェーン EIP-712 の
作法があり(ERC-1271 を RPC 経由で通す)、そこに乗せる。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/mcp-extraction.md の設計どおり、POST /mcp を Discord bot Worker から
独立した Worker へ移す。Discord は「確認チャネルのアダプタ」になり、
MCP を使うために Discord が必須ではなくなった。

- pkgs/extensions/mcp を新設。protocol / tools / queries / auth を移設し、
  トークンは tbn2.<treeId>.<tokenId>.<mac> に変更。ステートレス MAC →
  登録簿参照の 2 段検証で、個別失効ができるようになった
- discord-bot に POST /internal/propose を新設。propose の中身はここに残り、
  MCP 側は転送するだけ。ConfirmPayload を触るコード(confirm / button /
  discord-rest)と Turnkey 署名は discord-bot に残る
- read は treeId 引数で越境可(subgraph は公開)。ただし identity の解決
  (Discord <-> ウォレット)は home ワークスペースのみ
- /$treeId/settings からトークンを発行・一覧・失効できる導線を追加。
  認可は EIP-712 署名 + Hats subgraph の hat 保有を Worker 側で検証

実装中に見つけて塞いだもの:

- MCP_INTERNAL_PROPOSE_SECRET を追加。service binding だけでは
  discord-bot の公開 URL への直接 POST を止められないので、これが無いと
  MCP トークンを持たない第三者が任意チャンネルに確認ボタンを投稿できた
- list と revoke が同一の EIP-712 ペイロードを共有し、かつ list は nonce を
  消費しない設計だったため、一覧表示用に管理者が作った署名を有効期限内に
  revoke へ流用できた。署名が操作にも対象にも紐づいていなかったのが原因で、
  McpTokenListRequest と McpTokenRevokeRequest(tokenId を署名対象に含む)に
  分割。回帰テストを追加してある
- フロントが typed-data の envelope を message キーで送っていて、
  ハンドラが読む typedData と食い違っていた(本番なら発行も失効も 400)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1. 一覧・失効 UI が死んでいた。フロントが GET /api/mcp-tokens を叩いて
   いたが、Worker の実装は POST /api/mcp-tokens/list + 署名。405 が返り、
   一覧は常に空、失効ボタンは行の中にしか無いので到達不能だった。
   署名はページ表示では行わず、明示操作(「署名して一覧を表示」)または
   発行直後のウォレットセッション再利用のみ。list は nonce を消費しない
   ので 30 分キャッシュして使い回す
2. pkgs/openclaw が削除済みの discord-bot/mcp を指したままだった
   (guilds.example.json / setup.md / render-config.ts / skills/README.md、
   加えて openclaw/CLAUDE.md にも 1 箇所)
3. lookupDiscordIds が identity のバッチ上限 200 を超えると 400 を握り潰し、
   全員 null を返していた。エージェントはワークスペース全員を「未連携」と
   報告する。200 件ずつ分割し、部分失敗は identityLookupDegraded で
   「連携なし」と区別できるようにした
4. 発行の nonce をトークン挿入の後に burn していたため、1 つの署名から
   複数の有効トークンが作れた。PRIMARY KEY を実際の排他にするため
   insertToken より前へ移動
5. SKILL.md が「read はトークンのワークスペースに限定される」と書いていたが
   越境 read を許す実装と矛盾していた
6. verifyTypedData に渡す types / domain を定数に固定(悪用は不可能だが、
   その推論を毎回する必要をなくす)

4 についてはレビュー修正が行き過ぎていたので調整した。署名検証より前に
burn すると、このエンドポイントは公開(ブラウザから直接叩く)なので
第三者が署名なしで任意の nonce 行を挿入でき、剪定経路の無いテーブルが
無限に膨らむ。署名検証の後、insertToken の前が正しい位置。
両側の境界を固定する回帰テストを 2 本置いた。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 観点(reuse / simplification / efficiency / altitude)のレビュー結果。
振る舞いを変える修正は入れていない。

@toban/mcp:
- issue.ts が list.ts の認証エンベロープを約 100 行再実装していたので、
  共有部分を handlers/auth.ts に出して 3 ハンドラで共有。issue.ts は
  nonce burn の位置を保つため結合済み関数ではなく部品を直接呼ぶ
- json() / errorResponse() の 4 コピーを src/http.ts に統合(1 つだけ
  引数順が逆でコピペ事故の温床だった)
- 越境 identity のゲートを 5 箇所のコピーから 2 つの choke point へ。
  guardDiscordUserIdArg が引数解決の唯一の入口、lookupDiscordIds が
  逆引きの唯一の入口で、home 外なら内部で短絡する。identityLookupDegraded
  も同じ関数が整形するので呼び出し側が伝播を忘れられない
- IDENTITY_LOOKUP_CHUNK_SIZE を @toban/identity の MAX_WALLETS_PER_BATCH
  の import に変更。200 の二重定義をやめ、identity 側の変更が
  typecheck で落ちるようにした
- toban_member_status の Goldsky 3 本を 1 本のエイリアスクエリへ
  (subrequest 4 → 2、送付提案の直前に必ず通るパス)
- verifyWalletTreeAuth / revoke.ts / worker.ts の直列・重複を解消
- orderBy(desc()) 化、treeId ガードの 6 重複を畳む、未使用の注入口を削除

frontend:
- handleIssue / handleRevoke を mcp-tokens.ts の純関数へ(テスト 8 本追加)。
  route 配下はユニットテストできず、それが list の形不一致を見逃した原因
  だったのに、issue / revoke は同じ穴のまま残っていた
- 手組みの domain を buildMcpTokenDomain に統一

discord-bot:
- postGraphQL を private に戻す(切り出し後は外部利用者がいない)
- src/mcp/ → src/confirm/ に改名。MCP サーバーは別パッケージになり、
  残っているのは ConfirmPayload を触る 3 ファイルだけ

docs:
- §8 に、見送った約 240 行の cross-package 重複を決定と理由つきで記録
  (chain.ts の resolver 群、IdentityFetchClient の transport、テスト
  fixtures、resolveChain)。5 つ目の共有パッケージか @toban/identity の
  subpath 追加が必要で、差分外の既存コードに手が入るため follow-up にした
- propose の wire contract が Discord の形をしていることも既知のコストと
  して記録。2 つ目のアダプタで third-party 依存の改名が必要になる

revoke.ts の nonce burn は早期化していない。失効は冪等なので issuance の
「1 署名から複数の有効トークン」に相当する害が無く、早期化すると失敗した
失効が nonce を無駄に消費する副作用だけが増える。実装と食い違っていた
CLAUDE.md の記述を直した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yu23ki14
yu23ki14 merged commit 281ebe8 into main Sep 16, 2026
4 checks passed

This branch was successfully deployed

1 active deployment
Preview — 17aa106d Deployed Sep 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant