やってみた 2026年9月30日

context-modeをエージェント抜きで叩いてみた。99.7%削減の表示と、denyルールが止めないもの

XECIN TypeScriptMCPAIエージェントOSS

GitHubのトレンドで mksglu/context-mode を見かけました。説明文には「Sandboxes tool output (98% reduction)」とあります。

運用を考えると、ここは素通りできない数字です。コーディングエージェントの利用料はおおむね会話に入るトークンの量で決まるので、その手前に「出力を縮める層」を1枚挟めるなら、効いてくるのは単価ではなく量の方です。ただ、層を1枚足すということは、そこで何かを捨てているということでもあります。私が先に知りたかったのは、何を捨てて、何を止めてくれるのか、でした。

そこで今回は、Claude Code などのエージェントは繋がずに、MCPサーバとフックを直接叩くことにしました。エージェントを間に挟むと、ツールが返したものとモデルが判断したものの区別がつかなくなるからです。

試した環境

  • 使い捨てのDockerコンテナ(node:22-bookworm、Node.js v22.23.1、npm 10.9.8、Debian 12.14)。メモリ4GB・2CPUに制限し、ホストのディレクトリはマウントしていません
  • context-mode 1.0.169(npmのlatest。検証時点のmainの最新コミット ab347abd でも、package.json の版は同じでした)
  • ライセンスは Elastic License 2.0 です。第三者向けにホスティングやマネージドサービスとして提供することを禁じる条項があるので、社内で使う前に一読しておくのが無難だと考えています
  • LLMやAPIキーは一切使っていません

入れて、MCPとして直接話しかける

導入は npm install -g 一発です。キャッシュを消して別の場所に入れ直して測ると、140パッケージで9.75秒でした。READMEにある claude mcp add context-mode -- npx -y context-mode は、要するにこのコマンドを標準入出力のMCPサーバとして起動しているだけなので、Nodeで書いた小さなクライアントから JSON-RPC を投げれば同じ状態を作れます。

# 導入(キャッシュを消し、別の prefix に入れ直して計測)
$ time npm install --prefix /tmp/fresh context-mode@1.0.169
added 140 packages in 10s
real=9.750

# 自作の MCP クライアントから initialize → tools/list
[init] 175ms server={"name":"context-mode","version":"1.0.169"}
[0] tools/list count=11 bytes=29751
   ctx_execute desc=3070B schema=1815B
   ctx_execute_file desc=2136B schema=893B
   ctx_index desc=1531B schema=1581B
   ctx_search desc=3363B schema=762B
   ctx_fetch_and_index desc=2248B schema=1448B
   ctx_batch_execute desc=2144B schema=2198B
   ctx_stats desc=184B schema=85B
   ctx_doctor desc=196B schema=85B
   ctx_upgrade desc=273B schema=85B
   ctx_purge desc=1559B schema=734B
   ctx_insight desc=392B schema=85B

最初に目についたのは、ツール定義の大きさです。11個のツールの説明文とスキーマを合わせて29,751バイトあります。いちばん長いのは ctx_search の説明文(3,363バイト)で、検索の使い方だけでなく、後で出てくる呼び出し回数の制限の話まで書き込まれています。

MCPクライアントがこの定義をそのままモデルに渡す作りなら、これが「入れているだけでセッションごとにかかる固定費」になります。実際にどう渡るかはクライアント次第なので、ここは今回は確かめていません。

373KBのログで「縮み方」を測る

題材として、コンテナの中で5,000行・373,777バイトのアクセスログ風のファイルを作りました。ほとんどが status=200 で、status=500 の行が5行だけ混ざっています。これを3通りの書き方で ctx_execute に読ませ、会話に戻ってくる文字列の大きさを測りました。

書き方戻ってきた大きさ中身
cat app.log(intent なし)180バイト「Indexed 1 sections (0 with code)」という索引済みの通知だけ。ログは1行も返らない
cat app.log(intent に「status=500 errors」)1,297バイト278セクションに分けて索引し、「5 sections matched」として5行を表示
grep -c ‘status=200’ app.log; grep ‘status=500’ app.log449バイト件数の4995と、status=500 の5行そのもの

この3回の後に ctx_stats を呼ぶと、「186.9K tokens saved · 99.7% reduction · < 1 min · ~$0.93 saved (Opus)」と出ました。内訳の棒グラフは「Without context-mode 732 KB / With 1.9 KB」です。

コストの比較は必ず並べ直して見る癖があるので、ここも分解してみました。732KBの大半は、2回の cat の生の出力(365.0KBずつ)です。つまり、それがそのまま会話に入っていたら、という仮定と比べた値です。一方で、最初から grep で絞る書き方をしていれば、3回目のように449バイトで済みます。つまり99.7%という削減率は「何と比べるか」でほぼ決まる数字で、生のログをそのまま読ませる使い方ほど大きく出ます。ツールが嘘をついているわけではなく、比較の相手がいちばん重いケースになっている、というだけなんですよね。

intentの見出しが、全部「正常な行」だった

いちばん戸惑ったのは2回目の結果です。「5 sections matched」の下に並んだ5行が、全部 status=200 の行でした。

# ctx_execute: cat app.log(intent: "status=500 errors")の戻り値から抜粋(全体で1,297バイト)
Indexed 278 sections from "execute:shell" into knowledge base.
5 sections matched "status=500 errors" (5001 lines, 365.0KB):

  - 2026-09-30T12:16:31Z INFO req=00991 path=/api/items/29 status=200 dur=44ms: 2026-09-30T12:16:31Z INFO req=00991 ...
  - 2026-09-30T12:49:49Z INFO req=02989 path=/api/items/29 status=200 dur=18ms: ...
  - 2026-09-30T12:33:01Z INFO req=01981 path=/api/items/20 status=200 dur=81ms: ...
  - 2026-09-30T12:06:19Z INFO req=03979 path=/api/items/20 status=200 dur=55ms: ...
  - 2026-09-30T12:22:49Z INFO req=04969 path=/api/items/11 status=200 dur=92ms: ...

Searchable terms: 00019, 00020, 00037, 00038, 00055, 00056, 00073, 00074, ...(全40語)

# ctx_search(queries: ["status=500"], source: "execute:shell") で1つ目のセクションを開く
### 2026-09-30T12:16:31Z INFO req=00991 path=/api/items/29 status=200 dur=44ms

2026-09-30T12:16:31Z INFO req=00991 path=/api/items/29 status=200 dur=44ms
2026-09-30T12:16:32Z INFO req=00992 path=/api/items/30 status=200 dur=51ms
2026-09-30T12:16:33Z INFO req=00993 path=/api/items/31 status=200 dur=58ms
2026-09-30T12:16:34Z INFO req=00994 path=/api/items/32 status=200 dur=65ms
2026-09-30T12:16:35Z INFO req=00995 path=/api/items/33 status=200 dur=72ms
2026-09-30T12:16:36Z INFO req=00996 path=/api/items/34 status=200 dur=79ms
2026-09-30T12:16:37Z INFO req=00997 path=/api/items/35 status=500 dur=86ms

最初はマッチ漏れを疑いました。ところが ctx_search で1つ目のセクションを開くと、7行目にちゃんと req=00997 ... status=500 がいます。5,001行を278セクションに分けているので、1セクションは平均で約18行です。当たったのはセクション単位で、見出しとして表示されるのはそのセクションの先頭行、という作りのようです。当たり自体は正しいのに、見出しだけ読むと外れに見えるわけです。「Searchable terms」として返ってきた40個の語も、00019 00020 のようなリクエスト番号ばかりでした。

ここは改善の余地がありそうだと思う一方で、使い方次第の部分でもあります。同じ形の行がひたすら並ぶログは、見出しで章立てされたMarkdownとは相性の悪い題材です。正直なところ、ログを相手にするなら intent に任せるより、3回目のように自分で grep を書いてしまう方が確実で、しかも小さく済むと考えています。

検索の回数制限は、READMEのとおりに効いた

ctx_search には、短時間に何度も呼ぶと結果を絞り、最後は拒否する仕組みがあります。READMEには「1〜3回目は通常、4〜8回目は1件ずつに減らして警告、9回目以降はブロック」とあります。同じプロセスから同じクエリを10回続けて投げてみました。

回戻ってきた大きさ末尾に付いた文言など
1〜2回目3,447バイト「Throttle: call #1/8 in this window. 2 call(s) before soft cap.」(2回目は #2/8)
3回目3,457バイト「search call #3/8 in this window. Results limited to 2/query. 5 call(s) remaining before block.」
4〜8回目1,824バイト「Results limited to 1/query」。結果が1件に減る
9〜10回目162〜163バイト(isError)「BLOCKED: 9 search calls in 0s. You’re flooding context. STOP making individual search calls.」

警告の文言が出始めるのは3回目からで、結果が実際に減るのは4回目からでした。ソースを見ると、数える窓の幅は既定で60秒です。CONTEXT_MODE_SEARCH_WINDOW_MS などの環境変数で変えられることは、ツールの説明文にも書かれています。9回目のブロックは isError: true で返るので、エージェントからはツールの失敗として見えます。

運用を考えると、この上限は「暴走しかけたエージェントを止める安全弁」として素直に効いていると考えています。

denyルールは「コマンド文字列」を見ている

context-mode は、Claude Code の設定ファイル(~/.claude/settings.json など)にある permissions.deny を読んで、ctx_execute に渡されたコードにも当てはめます。試しに Bash(rm:*) を1行だけ置いて、ファイルを消す書き方を8通り投げました。

書き方結果
shell: rm -f nothing.txt; echo plain-done止まった
shell: touch v1 && rm v1 && echo removed-v1(連結)止まった
python: os.system(‘rm -f v3’)止まった(埋め込まれたシェルコマンドとして検出)
python: subprocess.run([‘rm’,‘-f’,‘v4’])止まった
javascript: require(‘child_process’).execSync(‘rm -f v7’)止まった
shell: /bin/rm -f v2通って、ファイルが消えた
python: os.remove(‘v5’)通って、ファイルが消えた
python: os.system(‘r’ + ‘m -f v6’)通って、ファイルが消えた

表のコードは要点だけを抜き出しています。実際には前後に、消す対象のファイルを作る行と、消えたかどうかを確かめる print / echo を付けて投げました。

# 止まったとき(isError: true)
Command blocked by security policy: matches deny pattern Bash(rm:*)
Command blocked by security policy: embedded shell command "rm -f v3" matches deny pattern Bash(rm:*)

# /bin/rm で書いたとき(Exit code 2 は後ろの ls が失敗したため。v2 はもう無い)
Exit code: 2

stdout:
abs-done
ls: cannot access 'v2': No such file or directory

# os.remove と、文字列を組み立てた os.system(False は「ファイルはもう無い」)
os.remove-done False
concat-done False

&& で前に無害なコマンドを置いても分解して見ていますし、Pythonの subprocess のリスト形式まで拾っているので、作り込みはかなり丁寧です。それでも、見ているのはあくまでコマンドの文字列なので、絶対パスで書いたり、シェルを通さないAPIで消したり、文字列を組み立てたりすると通ります。ソースにも、サーバ側のこの検査は失敗時に通す(fail-open)作りで、主な防御はフック側だというコメントがあります。

READMEの「sandbox」は、生の出力を会話に入れないための箱で、ファイルやネットワークを閉じ込める箱ではない、と読むのが正確だと考えています。次に出てくるフックのメッセージ自身が「Both have full network access.」と書いているくらいです。権限の境界は、コンテナやOSのユーザー権限の側で作ることが重要です。

フックは「サーバが生きているとき」だけ止める

最後に、Claude Code 用のフック(context-mode hook claude-code pretooluse)に、PreToolUse のJSONを標準入力から直接流しました。1つの入力につき1プロセスです。同じ6種類の入力を、MCPサーバを止めた状態と、裏で1本動かした状態の2回流しています。

入力MCPサーバ停止中MCPサーバ稼働中
Bash: curl -s https://example.com0バイト(何も言わずに通す)deny(662バイト)。ctx_execute か ctx_fetch_and_index へ誘導
WebFetch: https://example.com0バイトdeny(760バイト)。ctx_fetch_and_index へ誘導
Read: app.logヒントを添えて通す(486バイト)同じヒントを添えて通す(486バイト)
Bash: cat app.logヒントを添えて通す(767バイト)0バイト(同じ session_id では2回目なので出ない)
Grep: status=500ヒントを添えて通す(591バイト)0バイト(同上)
Bash: ls -la0バイト0バイト
# MCPサーバ稼働中(/tmp/context-mode-mcp-ready-670 がある状態)に curl を渡したときの出力
# 実際は1行。読みやすさのために途中で改行し、後半を省略しています
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",
 "permissionDecisionReason":"context-mode: curl/wget redirected. Call mcp__plugin_context-mode_context-mode__ctx_execute(language, code) to fetch the URL, derive your answer in code, and print only the result — the raw HTTP body stays in the sandbox instead of entering your conversation. ... Both have full network access. ..."}}

# cat app.log のヒントは session_id ごとに1回だけ
session=fresh-session-A bytes=767 decision= tip=1
session=fresh-session-A bytes=0 decision= tip=0
session=fresh-session-B bytes=767 decision= tip=1

止め方が二段構えになっているのが分かります。curl と WebFetch は、MCPサーバが動いているとき、つまり /tmp/context-mode-mcp-ready-<PID> という目印のファイルがあるときだけ deny して、代わりのツールへ誘導します。サーバが落ちているのに誘導すると、エージェントが存在しないツールを呼びに行って詰まるので、黙って通す方に倒しているのだと思います。ここは好みが分かれるところですが、私はこの倒し方は妥当だと考えています。止めたい場面で止まらないことより、作業そのものが止まることの方が、現場では痛いからです。

一方で、Read・cat・Grep は止めずに「こう書いた方が縮みますよ」というヒントを添えるだけで、cat のヒントは同じ session_id では1回しか出ませんでした。Read のヒントは同じ session_id でも2回とも出たので、種類によって扱いが違うようです。ここは追いきれていません。

READMEが掲げる削減率は、エージェントがこの誘導やヒントに従って ctx_ 系のツールを使うことが前提です。どれくらい従うかはモデル次第で、今回はエージェントを繋いでいないので確かめていません。

試してみての所感

私ならこう使う、という線を書いておきます。

  • 生のログやAPIレスポンスをそのまま読ませがちなチームには、入れる価値があると考えています。373KBが180バイトになる部分は、読み手が「索引を引く」操作に慣れる前提でならそのまま効きます
  • 削減率の表示は、比較の相手が「生の出力をそのまま入れた場合」です。社内でコスト削減の根拠にするなら、普段の書き方(grepで絞るなど)との比較を自分で取り直すべきです。今回で言えば、同じ情報に449バイトで届く書き方がありました
  • denyルールは「うっかり」を減らす仕組みとして扱い、権限の境界としては使いません。境界はコンテナとOSの権限で作ります
  • intent付きの要約は、見出しのある資料向きです。同じ形の行が並ぶログには、自分で書いた集計の方が確実でした

もう一つ気になっているのが、29,751バイトのツール定義という固定費です。今回のような大きな出力を1回でも扱えば十分に元が取れますが、小さな出力しか扱わない短いセッションでは、定義の方が大きくなる場面もありそうです。次は、無料で使えるモデルを繋いで、エージェントが実際にどれくらい誘導に従うのか、定義の固定費込みで収支がどうなるのかを測ってみるつもりです。