🐚

なぜCLIではなくMCPなのか — Bashだけで社内ドキュメント検索MCPサーバーを作る

に公開

社内の議事録、営業レポート、勤怠データ。ドキュメントは山ほどあるのに、探せるのは自分だけ。Slackで「あの件の議事録どこ?」と聞かれるたびに手動で探して共有する。この非対称を壊したかった。

自分がClaude Codeのターミナルから検索するだけならCLIツールで済む。だが、いずれ非エンジニアにもCoworkから使ってもらいたい。CoworkやClaude Chatはサンドボックス環境で動いているので、CLIツールでは届かない。そこでMCPサーバーとして作ることにした。

この記事では、PythonもNode.jsも使わず、bashとGoogle Apps Script(GAS)だけでMCPサーバーを作った話を書く。個人PoCの段階で、まだ組織のドキュメント管理すら整っていないところからの出発だ。対象読者はMCPサーバーを自作したい人と、社内ナレッジのAI化を考えている人。

3つの層 — データ集約・検索・出力

社内ドキュメントをAIに渡すには3つの問題を順に解く必要がある。

1つ目はデータ集約。生のMTGメモ、録画の書き起こし、Slackの決定ログ。これらをどう整形し、検索しやすい名称・メタデータ・ディレクトリ構造に落とすか。ここが一番泥臭い。

2つ目は検索。整形されたデータを、AIが探索できるインターフェースにする。これが今回のMCPサーバーの仕事で、この記事の主題だ。

3つ目は出力。検索で見つかった情報を、質問の文脈に合わせてどう組み替えるか。議事録の「決定事項」だけ抽出するのか、時系列で経緯を組み立てるのか。ここはClaude CodeのSkillsが担う領域で、前回の記事「Claude Code Skillsは作って終わりじゃない — 事後ログで改善サイクルを回す」で書いた改善サイクルがそのまま効く。

この3層はきれいに分離している。1層目が雑でも2層目は動く。3層目を変えても1層目に影響しない。PoC段階ではこの疎結合が助かる。どこから手をつけてもいい。

今回は2層目から着手した。理由は単純で、検索が動けば1層目の整備方針も見えてくるし、3層目のSkillsも試せるからだ。

Claude CodeとCowork/Chatは動く場所が違う

まずClaude製品の構造を整理する。ここを理解しないと、なぜCLIではなくMCPなのかがわからない。

Claude Code(ターミナル版)は、ユーザーのマシンで直接動く

Claude Codeはローカルプロセスだ。bashツールでコマンドを実行し、ファイルを読み書きし、ネットワークにもアクセスできる。だからCLIツールをインストールすれば、Claude Codeから呼べる。社内ドキュメントの検索もdocs search "田中 Q1"みたいなコマンドで済む。

Claude Cowork / Claude Chatは、サンドボックスで動く

一方、Coworkはユーザーのマシン上で動くがサンドボックスで制限されており、Claude Chat(claude.ai)はAnthropicのクラウド上で動く。どちらも任意のCLIコマンドや外部ネットワークへのアクセスに制限がある。

Claude Code Cowork Chat (claude.ai)
実行場所 ユーザーのマシン ユーザーのマシン(サンドボックス) Anthropicクラウド
ローカルファイル 読み書きできる 読み書きできる アクセスできない
任意のCLI実行 できる できない(制限あり) できない
外部ネットワーク 自由に使える 制限あり 制限あり
MCPサーバー 使える 使える 使える

表の最後の行がポイントだ。MCPサーバーはどの環境からでも使える。MCPサーバー自体はユーザーのホストマシン上で動く独立プロセスで、ブラウザを開くことも、ローカルポートでlistenすることも、外部APIを叩くこともできる。CoworkやChatにCLIの制限があっても、MCPサーバー経由なら社内のGASバックエンドと通信できる。

なぜこの違いが重要か

自分一人がClaude Codeのターミナルで使うだけならCLIで十分だ。だが、将来的に非エンジニアにも使ってもらいたい。非エンジニアがターミナルを開いてClaude Codeを起動することはまずない。Coworkを開いて「先週の議事録を探して」と話しかけるのが自然な使い方だ。

そのCoworkからローカルの情報にアクセスする唯一の手段がMCPサーバーになる。個人PoCの段階からMCPで作っておけば、Claude Code(ターミナル)からもCowork(サンドボックス)からも同じツールが使える。CLIで作って後からMCPに移植する手もあるが、プロトコルが違うので書き直しになる。

grepだけでは足りない — コンテキスト汚染の問題

MCPサーバーで何を公開するか。ツール設計が一番大事な部分だ。

Claude Code開発者のBoris Chernyが、Claude Codeの設計思想について語っている動画がある。RAGを試して捨てた話、globとgrepだけでコードベースを探索させる「agentic search」の話など、今回のMCPサーバー設計の着想元になった。

https://www.youtube.com/watch?v=julbw1JuAz0

ただし、grepベースの探索をそのまま社内ドキュメントに持ち込むと問題が起きる。grepは条件に一致する行を全部返す。コードベースならそれでいい。だが社内ドキュメントの場合、議事録1件が数千文字あり、grepで10件ヒットすれば数万文字がコンテキストに流れ込む。モデルのコンテキストウィンドウがノイズで埋まる。これがコンテキスト汚染だ。

大事なのはツールの側でフィルタリングすることだ。docs_searchはタイトルと要約だけを返し、全文は返さない。docs_readはセクション単位で読める。docs_timelineは月別にグルーピングして鮮度スコア付きで返す。ツールのインターフェースが、コンテキストに入る情報の形と量を制御している。

実装言語がbashかPythonかは些事だ。MCPサーバーの設計で本質的なのは、モデルのコンテキストに何を、どういう粒度で渡すかというフィルタリングの設計にある。docs_searchdocs_read(セクション指定) → docs_relatedで段階的に社内ドキュメントを探索させ、各段階でコンテキストに入る情報量を制御する。これがツール設計の仕事だ。

Bash純正で書く480行のMCPサーバー

MCPサーバーの自作というと、TypeScript SDKかPython SDKが定番だ。bashで書いた理由は3つある。

依存ゼロにしたかった。将来的に非エンジニアにも展開する想定で作っている。Node.jsのインストールを頼む時点で大半は脱落する。bashならmacOSとLinuxには最初から入っている。WindowsもGit BashがあればOK。外部ツールはjq、curl、nc(netcat)の3つだけで、インストーラが自動で入れる。

MCPプロトコルの構造がbash向きだった。MCPはstdio上のJSON-RPCだ。標準入力から1行ずつJSONを読み、標準出力にJSONを返す。bashのreadechoで成立する。

透明性。bashスクリプトは誰でも読める。「このツールは何をしているのか」が気になったら、ファイルを開けば5分で追える。

アーキテクチャ

MCPサーバーの仕事は2つ。Claude Codeからのリクエストを受けてGASに転送すること。Google OAuthの認証フローを処理すること。

メインループ

while IFS= read -r line; do
  [[ -z "$line" ]] && continue
  [[ "$line" == Content-Length:* ]] && continue
  [[ "$line" == $'\r' ]] && continue

  if ! echo "$line" | jq -e . >/dev/null 2>&1; then
    continue
  fi

  method=$(echo "$line" | jq -r '.method // empty')
  id=$(echo "$line" | jq -c '.id // null')

  case "$method" in
    initialize)              # バージョンとcapabilitiesを返す ;;
    notifications/initialized) continue ;;  # 通知、レスポンス不要
    tools/list)              # 9つのツール定義を返す ;;
    tools/call)              # ツール実行 → GASに転送 ;;
    ping)                    # {} を返す ;;
    *)                       # Method not found ;;
  esac

  echo "$response"
done

stdioから1行読み、JSONかどうか判定し、メソッドで分岐する。Content-Length:ヘッダーやCR文字が混入するケースにも対応している。MCPのトランスポート仕様上、これらのノイズが飛んでくることがある。

ツール呼び出しはGASへのHTTP GETに変換される。

call_gas() {
  local TOKEN
  TOKEN=$(get_access_token)
  curl -s -L \
    -H "Authorization: Bearer ${TOKEN}" \
    "${GAS_URL}?$1"
}

GASが{"ok":true,"data":{...}}を返せば成功、{"ok":false,"error":"..."}ならエラー。それをMCPのtools/callレスポンスに包んで返す。

ncでOAuth

Google OAuth 2.0のDesktop App Flowを、Pythonのhttp.serverもexpressも使わず、ncだけで実装した。ここが一番面白い部分だ。

run_oauth_flow() {
  local PORT=8085
  local REDIRECT="http://127.0.0.1:${PORT}"

  # ブラウザでGoogle認証画面を開く
  open "$AUTH_URL"

  # ncで1回だけHTTPリクエストを受ける
  local RESPONSE_BODY='<html><body><h2>認証完了!このタブを閉じてください。</h2></body></html>'
  local RESPONSE
  RESPONSE=$(printf "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n%s" "$RESPONSE_BODY")

  local REQUEST
  REQUEST=$(echo "$RESPONSE" | nc -l 127.0.0.1 "${PORT}" | head -1)

  # GET /?code=xxx から認証コードを抽出
  AUTH_CODE=$(echo "$REQUEST" | sed -n 's/.*[?&]code=\([^& ]*\).*/\1/p')

  # トークン交換
  curl -s -X POST "https://oauth2.googleapis.com/token" \
    -d "code=${AUTH_CODE}" \
    -d "client_id=${CLIENT_ID}" \
    -d "client_secret=${CLIENT_SECRET}" \
    -d "redirect_uri=${REDIRECT}" \
    -d "grant_type=authorization_code"
}

echo "$RESPONSE" | nc -l 127.0.0.1 8085がキモになる。ncのstdinにHTTPレスポンスをパイプし、ncのstdout(ブラウザから受けたリクエスト)をキャプチャする。ブラウザがリダイレクトURLにアクセスすると、ncは「認証完了!」のHTMLを返しつつ、GET /?code=...のリクエスト行を流す。1回限りのHTTPサーバーとして機能する。

リフレッシュトークンは~/.config/docs-reader/token.jsonchmod 600(所有者のみ読み書き可)で保存。2回目以降は自動更新され、利用者には何も聞かれない。

セキュリティ設計

PoC段階だが、セキュリティは最初から意識して設計した。ここを雑にすると後で取り返しがつかない。

OAuthコールバックの安全性。 ncで127.0.0.1をlistenしてコールバックを受ける方式は、RFC 8252(OAuth 2.0 for Native Apps)で推奨されているloopback redirectパターンそのものだ。gcloud CLIやAWS CLIも同じ方式を使っている。HTTPで受けるが、通信がローカルマシンの外に出ないためRFC上も問題ない。

RFC 8252が求める対策は実装済みだ。PKCE(Proof Key for Code Exchange)で認証コードとクライアントを暗号的に紐付け、仮にコードを横取りされても悪用できないようにしている。openssl randでcode_verifierを生成し、openssl dgst -sha256でcode_challengeを作る。bashだけで完結する。state parameterによるCSRF対策も入れている。ポートはOSにエフェメラルポートを割り当てさせ、固定ポートへのハイジャックリスクを排除した。

トークンの保管。 ~/.config/docs-reader/token.jsonchmod 600で保存し、所有者以外の読み取りを防いでいる。ディレクトリ自体もchmod 700で作成する。macOSのKeychainやLinuxのsecret-serviceを使う方が堅牢だが、クロスプラットフォームでbash縛りだとファイル保存が現実的な落とし所になる。

GASを認証情報の境界線にする。 ここが設計上一番大事な判断だ。GASは単なるバックエンドではなく、秘密情報の隔離境界として機能する。GitHub PATやGoogle DriveのサービスアカウントキーはGASのScript Propertiesに持たせ、利用者側のスクリプトには一切渡さない。利用者が持つのはGoogleアカウントの認証だけ。仮にbashスクリプトの中身を全部読まれても、GitHubやDriveの認証情報には到達できない。

GASをウェブアプリとしてデプロイするとき、公開範囲を「ドメイン内」に限定し、executeAs: "USER_ACCESSING"を指定している。これにより、社外からはGASのURLを知っていてもアクセスできず、社内ユーザーでもそのユーザー自身のGoogle Driveパーミッションが適用される。RBACを自前で実装せず、Google Workspaceのドメイン制御と権限管理にそのまま乗る設計だ。

ツール設計

MCPサーバーが公開するツールは9つ。

ツール 用途
docs_search キーワード・種別・人名・期間で横断検索
docs_read セクション単位で文書を読む
docs_related 関連文書をたどる(前後関係、同一顧客)
docs_entity 人物・顧客単位の横断検索
docs_dataset 売上・勤怠の数値データ
docs_timeline 月別の時系列表示(穴の検出付き)
docs_ls / docs_tree フォルダ構造の確認
docs_reindex インデックス再構築

前述のコンテキスト汚染を避けるために、各ツールが返す情報量を意図的に制限している。docs_searchは要約のみ、docs_readはセクション単位、docs_timelineは月別グルーピング。モデルは段階的に情報を絞り込み、必要な粒度だけコンテキストに入れる。

SKILL.mdファイルにこの探索の定石を書いておくと、モデルはそれを参照して適切な手順を組む。

種別 戦略
事実確認 A社の契約金額は? searchread(1文書)
経緯追跡 A社商談の経緯は? timelineread で詳細
人物分析 田中のQ1実績 timeline + dataset(sales) + dataset(attendance)

Skillsとの接続 — 検索した後どう出力するか

MCPサーバーで検索できるようになった。次の問題は「検索結果をどう組み替えて出力するか」だ。

「A社の商談経緯を教えて」と聞かれたとき、議事録を日付順に並べるだけでは不十分だ。「決定事項」を抽出し、時系列で因果関係を組み立て、抜けている月を指摘する。この出力設計はClaude CodeのSkillsが担う。

SKILL.mdに書いた探索パターン(前述の表)がまさにこの層だ。検索ツールの呼び方だけでなく、結果の組み立て方まで含めて「Skill」として定義している。

ここで前回の記事「Claude Code Skillsは作って終わりじゃない」の話が繋がる。Skillは作った直後がピークではない。使うたびに「段落が長い」「Slack向けのフォーマットにして」といった修正が入る。その修正をログとして拾い、Skill自体を改善するサイクルを回す仕組みだ。

MCPサーバー(検索層)とSkills(出力層)の組み合わせで考えると、改善サイクルの対象が明確になる。

  • 検索がヒットしない → MCPサーバーのツール設計やGASの検索ロジックの問題
  • 検索はヒットするが出力が使いにくい → Skillの出力設計の問題
  • そもそも検索すべきデータがない → データ集約層の問題

問題の切り分けが3層の分離と対応する。改善を回す単位が小さくなるから、直しやすい。

非エンジニアに届ける設計思想

「技術者が作って、非エンジニアがCoworkから使う」モデルは、社内ドキュメントでも成立するはずだ。今はまだ個人PoCの段階だが、展開を見据えた設計にしている。

インストーラは管理者と利用者で役割を分ける想定だ。管理者がGCPのOAuthアプリ作成、GASデプロイ、認証情報の埋め込みを済ませ、zipで配布する。利用者はzipを展開してinstall.shを実行するだけ。

install.shの中でOS判定と依存インストールを自動化している。macOSならbrew、WindowsならGitHub Releasesからjqバイナリを直接落とす。claude_desktop_config.jsonへのMCPサーバー登録まで全自動。

インストール後のメッセージは日本語3行で終わる設計にした。

1. Claude Desktop を再起動
2. Cowork で「議事録を探して」と話しかける
   → 初回はブラウザが開いてGoogle認証
   → 以降は自動

JSON-RPCもstdioトランスポートもOAuthフローも、利用者からは見えない。ここまでのハードルを下げられれば、展開は現実的になる。

PoC段階で見えていること

正直に書くと、今の段階で最も整備が遅れているのは1層目のデータ集約だ。生のMTGデータをどういう名称規則で、どんなメタデータを付けて、どのディレクトリ構造で管理するか。ここが定まっていないから、検索の精度もそこに引きずられる。

逆に言えば、2層目(MCP)と3層目(Skills)を先に動かしたことで、1層目に何が必要かが見えてきた。「docs_searchで議事録を探すとき、日付と参加者がメタデータにないと絞れない」「docs_timelineを使うなら月ごとのディレクトリ構造が要る」。下流から上流の要件が逆流する。

bash実装の制約も見えている。同期処理しかできないので、サブエージェントが並行してツールを叩く場面で直列になる。GASのコールドスタートが朝一番で数秒かかる。

だが480行のbashスクリプトとGASの組み合わせで、Coworkから「田中さんの先週の会議の決定事項を教えて」と聞けば答えが返る状態にはなった。

社内ドキュメントの問題は、情報がないことではない。情報はある。探せる人が限られていることだ。MCPサーバー1つでその壁が少し下がる。

社内にClaude CodeやCoworkを導入済みで、ドキュメントの検索を自動化したい人は、まずMCPサーバーを1つ作ってみることを勧める。実装言語は何でもいい。大事なのはツールが返す情報の粒度を設計することだ。grepの結果を丸ごと渡すのではなく、コンテキストに入れる情報を意図的に制御する。そこがMCPサーバーの設計の核になる。

Discussion