Skip to content

Wiring an Idol Agency for Data-Driven Management

事務所の経営判断を、できるだけ勘ではなく数字に寄せたい。売上、ライブ動員、スタジオ稼働率、メンバー別の個人売上、スタッフの稼働、Discord 上でのやり取りの量と方向——「数字で見える範囲」を少しずつ広げていく、というのが現状だ。「データドリブンに回している」と言い切れるほどではないが、そこを目指して動いている。データそのものは MySQL に集まりつつある。集める仕組みは「Dashboarding an Idol Agency with Metabase and Cloudflare Tunnel」で書いた。可視化は Metabase に任せている。

ただ、ダッシュボードを開いて眺める運用は、思いついた問いに答える速さが足りなかった。「先週のライブ会場ごとの動員と、その日の物販単価の関係は?」「先月、特定メンバーが Discord 上で誰と一番やり取りしたか?」のような問いは、SQL を組み直しているうちに「まあいいか」になりがちだ。経営判断のリズムは、問いを投げるコストで決まる。

そこで、Claude Code から自然言語で事務所の DB に問えるようにしたかった。Metabase は深掘り分析の窓として残しつつ、日々の問いはチャットで投げ捨てる、という二刀流にしたい。MCP(Model Context Protocol)を本番に立てる、というのが答えだった。

ただ、これを安全かつ実用的に立てるのが思ったより面倒だった。「触れる範囲」を削るために三層認証とロール・可視性の二段フィルタを組み、「返り値」に詰める文脈を設計し、最後は絵文字の境界で JSON が壊れた。同じ失敗をしないように、書き残しておく。

なぜ stdio で済まないのか

MCP の transport

Model Context Protocol は LLM クライアントとサーバーの通信プロトコル。transport は stdio(標準入出力でローカルプロセスを叩く)と、HTTP(StreamableHTTPServerTransport で双方向通信)の2種類。stdio は手元で完結、HTTP はネットワーク越しに使える。

ローカルでだけ叩くなら stdio MCP で十分だ。Claude Code の .mcp.json にコマンドを書けば、子プロセスとして起動して使える。実際、手元だけで使う別リポジトリで stdio MCP を立て、事務所の業務データに対してデータドリブンな問いを Claude に投げる、という運用を少し前からやっていた。

ただ、これだと2つ困ることがあった。ひとつは、データソースが増えるたびに本番からローカルへ mysqldump で同期する手間が積み上がること。鮮度も落ちるし、朝の売上取り込みや日中のスケジュール同期、Discord メッセージの取り込みを反映するために毎回引き直すのは現実的でない。もうひとつは、stdio MCP が手元のプロセスに閉じていて、出先のラップトップから同じ問いを投げられないこと。

MCP サーバー自体を本番側に立てて、Claude Code から HTTP で叩くのが筋が良い。そうなると、認証をどうするかが先に立つ。

Discord メッセージは別の理由でコピーを持つ

認証の話に入る前に、データソースの話を片付けておく。事務所の Discord にあるメッセージを Claude から検索したかった。最初の素朴な案は「MCP ツールが Discord API を叩いて検索結果を返す」だったが、すぐに2つの壁にぶつかった。

ひとつ目は レート制限。Discord API は per-route と global の両方でレート制限を持っていて、GET /channels/{id}/messages を真面目に叩くと体感1秒に数リクエストで 429 が返る。retry_after を尊重して指数バックオフを書いても、Claude が tool calling で連続して投げる文脈では待ち時間が積み上がる。tool 1回が体感数秒で済まなくなった瞬間に、対話のリズムが壊れる。

ふたつ目は 検索の柔軟性。Discord の GET /guilds/{id}/messages/search は存在するが、UI ベースで使う想定の API で、複数チャネル横断・日本語の ngram 一致・期間とチャネルと投稿者の組み合わせフィルタ、といった事務所側で必要な検索を組み立てるには痩せている。返ってくるレスポンス形式も検索 UI に最適化されていて、後段で構造化処理するには使いづらい。

そこで、Discord メッセージのコピーを自前の MySQL に持つことにした。バッチで定期的にメッセージを取り込み、本文・投稿者・チャネル ID・タイムスタンプといった必要な情報を保存する。本文の検索性は WITH PARSER ngram の FULLTEXT インデックスでまかなう。これで MCP ツール側は MATCH ... AGAINST(? IN BOOLEAN MODE) を叩くだけで済む。

MySQL の ngram parser

標準の FULLTEXT インデックスは空白区切りで単語化するので、単語境界のない日本語ではほぼ機能しない。WITH PARSER ngram は文字列を N-gram(デフォルト2文字)に分割してインデックス化するので、MATCH ... AGAINST(? IN BOOLEAN MODE) で日本語の部分一致と boolean 演算(+用語1 -除外語 "完全フレーズ")が使える。

副次的な効果として、検索条件の柔軟性が一気に上がった。

  • 本文 + 投稿者 + チャネル + 期間の任意の組み合わせ
  • ngram + boolean mode で +用語1 -除外語 "完全フレーズ" が使える
  • 後段の構造化処理を見据えて、必要な情報を必要な形で返せる

「Discord 側の検索を MCP で薄くラップする」発想を捨てて、「事実のコピーを自分の DB に持って、そこに検索ツールを生やす」発想に切り替えると、API のレート制限と機能制限の両方から解放される。引き換えに、メッセージ同期バッチを書く必要が出るが、これは Discord Bot 側で稼働中のプロセスがあるので、そこに同期ループを生やすだけで済んだ。

メッセージのコピーを持つかどうか、というのはプライバシーの観点で慎重に検討すべき判断だが、自社の Discord ギルドを、自社運用の DB に持つという前提なら、Discord 上で見えているものと同じ範囲のデータを別の場所に置いているにすぎない。

ツールが返すのは match だけではない

データのコピーが手元にあるなら、次に頭を使うのは「ツールから何を返すか」だ。ここが MCP の設計で一番効いた箇所だと思っている。

素朴に作ると、search_messages はヒットしたメッセージそのものを返せばいい。SQL でいう MATCH ... AGAINST ... の結果セット。ただ、これだと Claude 側で次の判断ができない。「このメッセージは何の話の流れの中で出たのか」「誰への返信なのか」「同じ話題が他のチャネルに飛び火していないか」が見えないからだ。LLM の tool calling では、ツールの返り値がそのまま次の reasoning の入力になる。情報が足りなければ Claude はもう一度別の角度でツールを叩こうとして、対話のラリーが積み上がる。

そこで、ヒット周りに3つの情報を一緒に返す設計にした。

  • 前後 N メッセージ: 同じチャネル内で、ヒット時刻の前後にあった発言を UNION ALL で取って同梱する。会話の流れが見える。
  • reply 先の親メッセージ: ヒットが返信なら、親も添える。「質問への答え」がヒットしたとき、何への答えなのかが分かる。
  • チャネル名: ID ではなく人間が読めるチャネル名で添える。別チャネルのヒットが混ざっても話題が分離して見える。

これで search_messages 1回の呼び出しが、Claude にとって「何が起きているかを判断できる単位」になる。「先月メンバーAがメンバーBに返信したやり取り」と聞かれたとき、ヒットだけでなく前後と親まで含めた塊が返ってくる。Claude は次のターンで、もう一度ツールを叩く必要なしに会話の流れを要約できる。

同じ思想は他のツールにも効いている。reply_network は人ごとの集約と target 別 breakdown を1回で返し、get_channel_summary はメッセージ総数・top author・直近30日の日次推移を1回で返す。member_sales は個人売上・グループ売上・イベント売上・back amount を全部1行に並べる。「Claude が一回で判断できる粒度」を返り値に詰める、という方針だ。

ツール1回 = 対話1往復、と捉えると見えてくる。一往復で返せる情報量が増えるほど、対話のラリーが減る。pagination や stream を否定する話ではなく、初期応答に文脈をどれだけ詰めるか、の設計の話だ。

MCP のツールは「DBの薄いラッパー」ではなく、LLMが次の判断に必要な情報の塊を返す関数として設計する。これが、この基盤を組んで一番強く感じたことだ。

三つのドア

ようやく認証の話だ。事務所の MCP サーバーには、三つの入り口がある。

物理的には1つのプロセスだが、入り口ごとに通す認証方式が違う。

ドア経路認証
表玄関公衆Internet → Cloudflare TunnelGoogle OIDC + bearer token
管理者用 VPNWireGuard VPN → LAN信頼IP bypass
事務所内同一LAN(直接TCP)信頼IP bypass

表玄関だけは「正しい Google アカウントで来てください、その上で MCP の bearer をください」という扱いにする。残り2つは「ネットワーク的に届けたなら信頼する」というポリシーだ。LAN は事実上 root 扱いになるので、意図的に弱くしている。

表玄関:Google OIDC を MCP の OAuth プロバイダに被せる

MCP の OAuth フロー

MCP SDK は authorization_code + PKCE の OAuth サーバーを OAuthServerProvider インターフェースで実装させる。クライアントが authorize エンドポイントに飛んできたら、外部の IdP(Google)にリダイレクトし、コールバックで member 情報を引き、自前の auth code → access token を発行する。

MCP SDK が想定しているのは「自前で OAuth サーバーを立てる」形だ。そのまま自前のユーザーDB と password を持つ気はないので、Google OIDC を IdP として被せた。Google Workspace で事務所スタッフ全員に独自ドメインのアカウントを配っているので、認証(このユーザーは誰なのか)は Google に任せられる。ただし Google OIDC は認証だけしか提供しない——ドメインで弾くのも「この人は事務所メンバーか、どの役割か」の判定も、RP(このサーバー)側の責務になる。

なぜ Cloudflare Access で済まないか

Metabase の前段では Cloudflare Access に Google OIDC を被せて認証を済ませた。CF Access は IdP の前に立って認証フローをまるごと巻き取り、さらに「このメールドメインのみ許可」「このメールアドレスだけ許可」といったポリシーで認可まで一括して面倒を見てくれる。同じことが MCP でもできれば話は早いが、できない。CF Access はブラウザの session cookie に認証状態を載せる設計で、ブラウザでない Claude Code のような MCP クライアントとは噛み合わない。CF Access には非ブラウザ向けの service token があるが、これは1アプリケーションに1トークンの per-app 認証で、ユーザーごとの identity を MCP 側に渡せない。結果として、自前で OAuth サーバーを立てて Google OIDC を IdP として被せる方向に倒した——その代わり、CF Access が肩代わりしてくれていた認可(ドメイン絞り込み + メンバー判定)は RP 側で実装することになる。

authorize() で Google の認可エンドポイントにリダイレクト、/oauth/google/callback で id_token を JWKS 経由で検証、email claim を取り出す。そこから自前のメンバー情報を引いて、director か manager に該当する有効なアカウントだけを通す。退職済みは弾く。

文字で書くと込み入って見えるが、絵にすると OAuth 2.0 の authorization code + PKCE フローそのものだ。

ステップ1〜9が認証、10が認可済みの実利用。PKCE の code_verifier を発行側(Claude Code)と引き換え時(/token)の両方で握ることで、authorization code が傍受されてもトークン交換できないようにする。事務所側で id_token を捨てて自前の access token を発行しているのは、Google アカウントの存在 ≠ 事務所メンバーだから——退職済みやそもそもメンバーでない Google アカウントは、ステップ5でメンバー情報の引き当てに失敗して弾かれる。

これで「Google アカウントで認証された、かつ事務所の役員/マネージャーである」が成立する。authorization code の TTL は10分、access token は24時間。

管理者用 VPN と事務所内:信頼IPで bypass

LAN や WireGuard 経由の場合、毎回 OIDC を踏むのはオーバーキルだ。プレフィックスマッチで信頼してしまう。

ts
const DEFAULT_TRUSTED_PREFIXES = [
  '10.x.y.',             // 事務所LAN
  '127.0.0.1',
  '::1',
  '::ffff:127.0.0.1',
  '::ffff:10.x.y.',
]

LAN は /23 で切ってあるので、それをカバーするように2つのプレフィックスを並べている。isLocalTrusted() が IP のプレフィックスを見て、合致したら bearer auth をスキップして次のミドルウェアに進む。

LAN を root 扱いにするポリシーは意図的だ。物理的にこの LAN に届ける時点で、それは事務所の中の人間か、VPN を通してきた管理者(わたしだけが鍵を持っている)に絞られている。ネットワーク境界そのものを認証の代わりにしている、と言ってもいい。

絵文字で死ぬ JSON

Discord の search_messages ツールを叩き始めて2日目、こういうエラーが Claude API から返ってきた。

400 Bad Request: The request body is not valid JSON:
no low surrogate in string

何も悪いことはしていない(つもりだった)。Discord のメッセージを ngram FULLTEXT で検索して、長い本文は前後60文字でスニペット化して返す、というだけのツールだ。

UTF-16 サロゲート対

JavaScript の文字列は UTF-16 で内部表現される。BMP(U+0000〜U+FFFF)の外の文字(ほとんどの絵文字)は、2つの 16ビット単位(high surrogate U+D800〜U+DBFF + low surrogate U+DC00〜U+DFFF)でエンコードされる。String.prototype.slice() はこの単位を意識せずに切るので、サロゲート対の真ん中で切ると lone surrogate が残る。

スニペットの切り出しは、こういう素朴な実装だった。

ts
const RADIUS = 60
return normalized.slice(start, end)  // start/end が surrogate の中に当たる

絵文字(たとえば 🌸 = U+1F338 = D83C DF38)を含むメッセージで start がちょうど D83C と DF38 の間に来ると、片方の surrogate だけが残る。

JS 内部では String.length も問題なく動く。console.log も画面に化け文字(U+FFFD)として出るだけだ。問題が顕在化するのは、JSON.stringify を経由して外に出すときで、その JSON を厳格にパースする側(Claude API)が「low surrogate がないぞ」と落とす。

修正は短い関数1つだ。

ts
export function safeSlice(s: string, start: number, end?: number): string {
  let out = s.slice(start, end)
  if (out.length > 0) {
    const first = out.charCodeAt(0)
    if (first >= 0xdc00 && first <= 0xdfff) out = out.slice(1)
  }
  if (out.length > 0) {
    const last = out.charCodeAt(out.length - 1)
    if (last >= 0xd800 && last <= 0xdbff) out = out.slice(0, -1)
  }
  return out
}

切り出した文字列の先頭が low surrogate なら1文字落とす、末尾が high surrogate なら1文字落とす。これを MCP ツール側のスニペット生成と、list_channels のチャネル説明(topic)の60字省略に適用した。

その数日後、Discord Bot 側で同じことが起きた。Discord のメッセージには2000字制限があり、Bot が長いレスポンスを送るときは splitMessage で分割する。改行で切れない最終手段として2000字ぴったりで切るのだが、ここでも絵文字の境界に当たると lone surrogate が残る。Discord API は JSON 内の lone surrogate を \uD83C のような escape として受理した上で、表示時に U+FFFD に置換する。だから Discord 側の表示は化けるだけで済むが、その後 Discord API に投げ返したり、ログを Claude に食わせたりすると、より厳格な JSON parser の側で同じ400が出る。

結局 safeSlicesrc/lib/string.ts に切り出して、MCP と Bot で共有した。

教訓は2つある。一つ、JS 文字列を外に出すなら lone surrogate を疑うslice()substring()substr() も surrogate を意識しない。二つ、画面で化けても落ちないからといって、JSON 経由でも安全とは限らない。Discord は U+FFFD で許してくれるが、Claude API は許してくれない。

二段階のフィルタ:役割と可視性

認証が通っても、ツールが何でも返していいわけではない。事務所のデータには階層がある。

  • メンバー情報の編集や、データ基盤側の管理操作は director だけ
  • 売上・スケジュール・予約・Discord 履歴の参照は director + manager
  • talent(タレント本人)は今は MCP に通していない(DB は直接触らせず、Discord Bot 経由で自分の数値だけを返す)

ロールベースのチェックは、ツールの先頭で requireMcpRole を呼ぶ。

ts
server.tool('admin_only_action', '...', schema, async (args, extra) => {
  const auth = requireMcpRole(extra, ['director'])
  // ...
})

参照系のツールは director+manager の両方に開放、書き込み・編集が伴うツールは director のみ、というふうに分けている。

ただ、ロールだけだと足りない。manager は複数いる場合があり、それぞれ担当グループが違う。Discord 上では「manager 全員に見せていい」チャネルと「片方の manager にしか見せない」チャネルが別々に設定されている。MCP の Discord 系ツール(メッセージ取得・検索・活動統計など)が manager 全員に同じ結果を返すと、見えるべきでないやり取りが漏れる。

そこで、Discord 側のチャネル可視性を MCP のクエリにも反映する仕組みを足した。

Discord の permission overwrites

Discord のチャネル可視性は3層で決まる。(1) @everyone ロールのデフォルト permissions、(2) 各ロールに対する overwrite、(3) ユーザー個別の overwrite。後の層が前の層を上書きできる。最終的に VIEW_CHANNEL bit が ALLOW に倒れたユーザーだけがそのチャネルを見られる。permissions は bigint のビットフラグで保存される。

たとえば「@everyone で DENY、director ロールで ALLOW」と設定すれば、director ロール持ちだけが見られるチャネルになる。そこからさらに「この manager にだけ見せたい」なら、ユーザー個別 overwrite で当該 manager を ALLOW にする。

仕組みはこうだ。事務所の Discord ギルドのロールとメンバー情報を毎日同期するバッチがある。各チャネルの permission overwrites も同じく自前 DB に JSON で持っている。

MCP ツールは、caller の email から Discord ユーザー ID を引き、そのユーザーが持っているロール ID 群を取る。@everyone → 各ロール → ユーザーごと overwrite の順で ALLOW/DENY を畳んで、最終的に VIEW_CHANNEL bit が立つチャネルだけを集合に集める。それを Discord 系クエリの末尾に AND channel_id IN (?, ?, ...) として足す。

director はこの計算をスキップして全件見える。紐付けが壊れている manager(Discord ユーザー ID がメンバー行に紐づいていない、ギルドメンバーレコードが欠けている)は fail closed で空集合になる。「見えるはずなのに何も返らない」より「見えないはずのものが返る」方が事故が大きい、という判断だ。

director だけが叩ける管理ツール(メンバー情報の編集など)と、manager にも開放するけどチャネルで絞るツール(Discord系)。役割は権限の幅を、可視性は権限の射程を決める。両方を別々のレイヤーで実装することで、片方を変えてももう片方が壊れない。

締め

書き終えてから振り返ると、この MCP サーバーで頭を使ったのは2つの方向だった。「触れる範囲」をどう削るか、と「返り値」に何を詰めるか。

削る側:

  • 三層認証で「誰が叩いているか」を確定する
  • ロールで「叩いていいツールか」を絞る
  • 可視性で「返していいデータか」を絞る
  • write 系は director にしか出さない(メンバー情報の編集系など)
  • そして、JSON で外に出す前に lone surrogate を剥がす

詰める側:

  • 検索ヒットには前後 N 件と reply 親を同梱する
  • 集約系は breakdown も同じ応答に並べる
  • 「Claude が一回で判断できる粒度」を返り値の設計単位にする

削る側は引き算、詰める側は足し算だ。表玄関を開ける前に内側で何を返さないかを決めておくツールを生やす前に、その応答1回で何を判断できるべきかを決めておく。これは MCP に限らず、LLM が叩く本番 HTTP エージェントに当てはまる原則だと思う。

このサーバーが立ったあと、問いを投げて結果を眺めるまでのコストが体感で1段下がった。「先月の物販単価が上がってる気がするけど、会場別で見るとどう?」「先週、特定メンバーのスタジオ予約が偏ってる理由は?」のような問いを、ダッシュボードの設計を待たずに投げられる。Claude Code 側で SQL を組み立て、ツール経由で叩き、結果を要約してくれる。Metabase で深掘りする前に「そもそもこの仮説に意味があるか」を確かめる、その手前のレイヤーが立ち上がった。

データドリブンに経営を回すと言い切れる状態には、まだ遠い。仮説の立て方も、見るべき指標の選び方も、これから整えていく段階だ。それでも、問いを投げるコストが下がれば、判断のリズムはそれだけで変わる。MCP サーバーは、その下げ幅を一段増やす道具になった。lone surrogate のように本番に出してから気づく穴は今後も出るだろうが、塞ぎ方は共通の場所に置いてある。次の道具を立てるときも、同じ場所に塞ぎ方を畳み込めばいい。