概要
「あの話題、どこのチャンネルで話してたっけ……?」と、チャットツールの中を延々とさまよった経験は無いでしょうか。
弊社では社内チャットとして「RocketChat」を利用しています。RocketChat は、自前のサーバーでホストできるオープンソースのチャットツールとしては定番どころの一つで、Slack の代替として世界中で使われています(公式サイトによれば利用者は1,200万人以上)。ところがこの RocketChat、少なくとも私たちの環境では、Slack や Discord のような全チャンネルをまたぐ横断検索ができません。話題を探すには、チャンネルを一つずつ開いて検索して回ることになります。これが地味につらい。
補足: 最近の RocketChat では状況が変わりつつあります。管理者設定で有効化できるグローバル検索(ベータ)が存在するほか、2026年6月のバージョン 8.5 ではワークスペース全体を意味検索できる「Intelligent Search」も発表されました。とはいえ、自前ホストの既存環境をすぐに最新版へ上げられるとは限りませんし、後述の通り「AI に検索を代行させる」には結局 API の口が必要です。本記事のアプローチはそうした環境でこそ活きるものです。
そこで今回、AI (Claude Code) に横断検索を代行してもらうための「MCP サーバー」を自作してみました。「あの件どこで話してた?」と日本語で頼むだけで、AI がチャット全体を検索して答えてくれるようになります。この記事では、その設計と実装のポイントを紹介します。
目次
- MCP とは?
- 作るものの全体像
- 最初の壁:「横断検索 API」が存在しない
- 解決策: ファンアウト方式で自前実装する
- 実装を見てみましょう
- 細かいながらも大事な工夫
- 動かしてみた結果
- おわりに
1. MCP とは?
MCP (Model Context Protocol) とは、AI に外部ツールを繋ぐための共通規格です。2024年11月に Anthropic 社が公開したオープン標準で、その後 OpenAI や Google なども採用し、事実上の業界標準になりました。「AI にとっての USB-C」と例えられることもあります。
REST API との関係を整理すると、次のようになります。
| REST API | MCP | |
|---|---|---|
| 役割 | サービス側の「呼び出し口」 | AI とツールを繋ぐ「共通コネクタ」 |
| 呼び出し方 | サービスごとにバラバラ | どのツールでも共通 |
| 呼び出す主体 | 人間やプログラムが手順を決める | AI 自身が説明文を読んで判断する |
誤解されがちですが、MCP は API の代替品ではありません。MCP サーバーは内部で REST API を呼んでおり、いわばバラバラな API 群を AI が扱いやすい形に翻訳する「通訳」のような存在です。今回作る構成も、次のような三段構えになります。

2. 作るものの全体像
実装に入る前に、基本方針を4つ決めました。
- まず疎通検証から …… いきなり本実装せず、使い捨てスクリプトで API の実挙動を確認してから作る
- PAT 認証を使う …… Web 画面のログインが SSO 構成でも、REST API は Personal Access Token (PAT) で直接叩けます
- stdio 方式 …… 常駐サーバー不要。AI が必要なときにプロセスを起動する方式なので、手元のマシンだけで完結します
- 読み取り専用 …… 事故防止のため、機能は検索・読み取りのみ。投稿・編集・削除は最初からスコープ外としました
補足: PAT は RocketChat の「マイアカウント → Personal Access Tokens」から発行できます。API を叩く際は、リクエストヘッダーに
X-Auth-TokenとX-User-Idを付与するだけです。
開発環境も明示しておきましょう。実装言語は TypeScript(実行環境は Node.js 18以降)で、使った主なライブラリは次の通りです。
@modelcontextprotocol/sdk…… MCP 公式の TypeScript SDKzod…… 入力スキーマの定義・バリデーションvitest…… ユニットテスト
サーバー本体の骨組みは、公式 SDK のおかげで驚くほど短く書けます。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({ name: "rocketchat-mcp", version: "0.1.0" });
// ツール (機能) を登録していく
registerSearchMessages(server, client, config); // メッセージ横断検索
registerListChannels(server, client); // チャンネル一覧
registerListUsers(server, client); // ユーザー検索
registerGetThreadContext(server, client); // スレッド取得
// 標準入出力 (stdio) で AI と接続する
await server.connect(new StdioServerTransport());
3. 最初の壁:「横断検索 API」が存在しない
さて、本題の横断検索です。RocketChat には chat.search という検索 API があるのですが、仕様を見て途方に暮れました。部屋 ID (roomId) の指定が必須、つまり1部屋ずつしか検索できないのです。少なくとも私たちの環境で使える API の中に、横断検索に相当するものは見当たりませんでした。
Web 画面に横断検索が無いのですから、API にも無いのは道理ではあります。しかし、無いなら諦める……のではなく、無いなら組み合わせて作ればいいのです。
4. 解決策: ファンアウト方式で自前実装する
考え方はシンプルです。図書館で本を探すとき、一人で全部の書架を順番に回るより、複数人で手分けして探した方が早いですよね。それと同じことをやります。
- 参加中の部屋 (公開チャンネル・非公開チャンネル・DM) を
rooms.getAPI で全部列挙する - 各部屋に対して
chat.searchを並列で投げる (fan-out) - 返ってきた結果をマージして、時系列に並べ直す
「検索クエリを各部屋に扇状 (fan) に撒いて、結果を束ねる」ことから、この種のやり方はファンアウト (fan-out) と呼ばれます。私の造語ではなく、一つの入力を複数の宛先に分配する処理を指す、メッセージングや分散システムの分野では定番の用語です(参考: AWS SNS のファンアウトシナリオ、Fan-out (software) - Wikipedia)。

5. 実装を見てみましょう
並列処理の道具を作る
まず、「配列の各要素に非同期処理を、ただし同時実行数に上限を付けて」実行するユーティリティを用意します。全部屋に一斉にリクエストを投げるとサーバーに優しくないので、既定では6並列に絞っています。
/** items を最大 limit 並列で処理し、入力順を保った結果配列を返す。 */
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const size = Math.max(1, Math.min(limit, items.length || 1));
async function worker(): Promise<void> {
while (true) {
const i = next++;
if (i >= items.length) return;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: size }, () => worker()));
return results;
}
補足: 「ワーカーを N 個立ち上げて、仕事のキューを共有する」という定番パターンです。外部ライブラリ (p-limit など) を使う手もありますが、この程度なら自作しても20行です。
ファンアウト検索の本体
先ほどのユーティリティを使って、横断検索の本体はこう書けます。
export async function fanoutSearch(client, rooms, options) {
const target = rooms.slice(0, options.maxRooms);
let truncated = rooms.length > target.length; // 打ち切りが発生したか
// 各部屋を並列で検索する
const perRoom = await mapWithConcurrency(target, options.concurrency, async (room) => {
try {
return await client.searchMessages(room._id, options.searchText, options.perRoomCount);
} catch {
// 1 部屋の失敗 (権限なし等) は全体を止めない。空扱いにする
return [];
}
});
// マージ → 絞り込み → 新しい順にソート
const merged = perRoom
.flat()
.filter((m) => matchesFilters(m, options)) // 送信者・期間のポストフィルタ
.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
if (merged.length > options.limit) truncated = true;
return {
messages: merged.slice(0, options.limit),
roomsSearched: target.length,
roomsTotal: rooms.length,
truncated,
};
}
ポイントは、1部屋の検索が失敗しても全体を止めないことです。権限の都合などで一部の部屋だけエラーになることがあるため、失敗した部屋は「0件」として扱い、検索自体は最後まで完走させます。その分、失敗した部屋も部屋数の上では「検索済み」に数えてしまう大らかな作りですが、厳密さよりも完走を優先した割り切りです。
AI に公開する「ツール」を定義する
MCP サーバーでは、AI に使わせたい機能を「ツール」として登録します。このとき大事なのが、ツールの説明文 (description) は AI が読むものだということです。AI はこの説明文を読んで「いま search_messages を呼ぶべきだ」と自分で判断するので、人間向けのドキュメント以上に丁寧に書く必要があります。
server.registerTool(
"search_messages",
{
title: "メッセージ検索",
description:
"RocketChat のメッセージをキーワード検索する。channel 未指定なら参加中の" +
"全部屋(DM 含む)を横断検索する。username/since/until は取得後に絞り込む。",
inputSchema: {
query: z.string().describe("検索キーワード"),
channel: z.string().optional().describe("チャンネル名(#なし)。指定時はその部屋のみ検索"),
username: z.string().optional().describe("送信者のユーザー名で絞り込む"),
since: z.string().optional().describe("この日時以降(ISO8601、例 2024-01-01)"),
until: z.string().optional().describe("この日時以前(ISO8601)"),
limit: z.number().int().positive().max(200).optional().describe("最大件数(既定 30)"),
},
},
async (args) => { /* fanoutSearch を呼んで結果を整形して返す */ }
);
参考情報: 入力スキーマの定義には zod というバリデーションライブラリを使っています。「どんな引数を受け付けるか」を宣言的に書けて、AI への説明 (describe) も同じ場所に書けるので相性が良いです。
6. 細かいながらも大事な工夫
打ち切りは隠さない
参加部屋数が多い環境では、検索対象の部屋数に上限を設けています。ここで大事にしたのが、打ち切りが発生したら結果にはっきり書くことです。
const header =
`${result.roomsSearched}/${result.roomsTotal} 部屋を検索、${result.messages.length} 件` +
(result.truncated ? "(打ち切りあり:絞り込みや limit 増加を検討)" : "");
AI は返ってきたテキストを素直に信じます。黙って取りこぼすと、AI は「全部調べたが無かった」と誤った報告をしてしまいます。「50部屋中30部屋しか調べていない」と明示すれば、AI 側が「では条件を絞って再検索します」と次の手を打ったり、少なくとも「一部は未検索です」と正直に報告したりできるわけです。
絞り込みはポストフィルタで
chat.search には「送信者で絞る」「期間で絞る」といったパラメーターがありません。そこで、多めに取得してから手元で絞り込む方式にしました。その代わり、一度に取得する件数を超えたヒットは取りこぼす可能性があります。厳密な全件検索というより「当たりを付ける」ための道具、と割り切った設計です。
ツールは4つだけに絞る
公開したツールは「検索」「チャンネル一覧」「ユーザー検索」「スレッド取得」の4つだけです。ツールをたくさん生やすと、AI がどれを使うべきか迷い始めます。人間の UI 設計と同じで、選択肢を減らすことが使いやすさに直結するというのは面白い発見でした。
7. 動かしてみた結果
弊社の RocketChat サーバーは、正直に言ってかなり古いバージョンで動いています。「古すぎて API が動かないのでは」と心配していたのですが、結果は上々でした。
- PAT 認証・数十部屋の列挙 (DM 込み)・日本語キーワード検索、いずれも問題なく動作
- スレッド取得 API が未対応の場合に備えたフォールバックも用意したが、こちらも動作
- Claude Code に「あの件どこで話してた?」と日本語で頼むだけで、横断検索と絞り込みを組み合わせて調べてくれるように
特に感動したのは、AI がツールを組み合わせて使ってくれることです。「まず全部屋の横断検索で当たりを付け、反応のあったチャンネルに channel 指定で絞って掘り下げる」といった多段の調査を、こちらが指示しなくても勝手にやってくれます。ツールを4つ用意しただけで、その組み合わせ方は AI が考えてくれるのです。
8. おわりに
今回の学びをまとめると、次の3点です。
- API が無いなら作ればいい …… 「部屋列挙」+「並列の部屋内検索」という既存 API の組み合わせで、存在しなかった横断検索を実現できました
- 古い環境でも意外とどうにかなる …… Web 画面が SSO 構成でも、PAT 認証で REST API は直接叩けます
- 「まず検証」が効く …… 本実装の前に使い捨てスクリプトで API の実挙動を確かめるフェーズを挟んだことで、思い込みによる手戻りを早期に潰せました
MCP サーバーの自作というと難しそうに聞こえますが、公式 SDK のおかげで、実態は「REST API を叩く小さな CLI ツール」を書くのと大差ありません。皆さんの職場にも「API はあるのに横断検索が無い」「AI に調べさせたい社内システムがある」といった題材が転がっているのではないでしょうか。その一歩目として、この記事が参考になれば幸いです。
参考資料
- 閲覧数 10
コメントを追加