一行要約

MCP ツールを直接呼ばせるのをやめ、ツールをコードの API として提示してエージェントにコードを書かせると、ツール定義と中間結果の両方が context から外れ、実測で **150,000 トークン → 2,000 トークン(98.7% 削減)**になる。

要点

問題は2箇所でトークンを食っていること

MCP は「エージェント側に一度実装すればエコシステム全体が使える」普遍プロトコルとして普及し、コミュニティは数千の MCP server を作った。その結果、開発者は数十の MCP server にまたがる数百〜数千のツールを持つエージェントを日常的に組むようになった。ここで2つのトークン浪費が起きる。

1. ツール定義が context window を圧迫する

全ツールの定義が事前にロードされる。各定義は名前・説明・パラメータ・返り値スキーマを持つので、ツール数に比例して膨らむ。

2. 中間結果が追加のトークンを食う

ツールの出力が context に載り、次のツールへ渡すためにもう一度モデルが書き出す。2時間の営業ミーティングの文字起こしを Google Drive から読んで Salesforce に書き込む場合、同じ文字起こしが2回 context を通るため、追加で 50,000 トークンかかる。大きな文書では context window の上限を超えうる

解決策 — ツールをコードの API として出す

MCP server をファイルツリーとして提示し、各ツールを import 可能な関数にする。エージェントはツールを直接呼ぶ代わりにコードを書く

Agents scale better by writing code to call tools instead. LLMs are adept at writing code and developers should take advantage of this strength. エージェントは、ツールを直接呼ぶ代わりにコードを書いて呼ぶほうがよくスケールする。LLM はコードを書くのが得意であり、開発者はこの強みを活かすべきである。

5つの利点

1. progressive disclosure(漸進的開示)

全ツール定義を先読みさせず、ファイルシステムを探索させて必要なツールだけ読ませる。これが 150,000 → 2,000 トークンの主因。

2. context 効率の良いツール結果

中間結果が実行環境に留まり、context に載らない。10,000行のスプレッドシートをフィルタする場合、全行を context に流して手で絞る代わりに、実行環境で filter して先頭5件だけログ出力できる。

3. より強力で context 効率の良い制御フロー

ループ・条件分岐・待機をコードで書ける。「Slack にデプロイ完了メッセージが出るまで5秒おきにポーリング」を、ツール呼び出しのラウンドトリップなしに実行環境内で回せる。

Code execution applies these established patterns to agents, letting them use familiar programming constructs. コード実行は、こうした確立されたパターンをエージェントに適用し、使い慣れたプログラミングの構成要素を使わせるものである。

4. プライバシーを保つ操作

PII を含むデータを、モデルの context に一度も載せずにシステム間で移送できる。スプレッドシートから Salesforce へメール・電話・氏名を流す処理で、エージェントが見るのはトークン化された値([EMAIL_1][PHONE_1])だけにできる。

5. 状態の永続化と skill

実行環境のファイルシステムに中間結果を書けるので、実行をまたいで再開できる。さらに、繰り返す処理を関数として保存すれば skill として再利用できる。

注意点

Running agent-generated code requires a secure execution environment with appropriate sandboxing. エージェントが生成したコードを走らせるには、適切に sandbox 化された安全な実行環境が要る。

エージェントが生成したコードを走らせる以上、適切な sandboxing を備えた安全な実行環境が必要。原典は claude-code-sandboxing の記事を参照している。

同様の知見は Cloudflare も “code mode” として公表している、と原典が言及している。

そのまま使える具体例

問題の形(従来のツール呼び出し) — 同じ文字起こしが2回 context を通る:

TOOL CALL: gdrive.getDocument(documentId: "abc123")
        → returns "Discussed Q4 goals...\n[full transcript text]"
           (loaded into model context)
 
TOOL CALL: salesforce.updateRecord(
			objectType: "SalesMeeting",
			recordId: "00Q5f000001abcXYZ",
  			data: { "Notes": "Discussed Q4 goals...\n[full transcript text written out]" }
		)
		(model needs to write entire transcript into context again)

MCP server をファイルツリーとして提示する:

servers
├── google-drive
│   ├── getDocument.ts
│   ├── ... (other tools)
│   └── index.ts
├── salesforce
│   ├── updateRecord.ts
│   ├── ... (other tools)
│   └── index.ts
└── ... (other servers)

1ツール = 1ファイルの薄いラッパ:

// ./servers/google-drive/getDocument.ts
import { callMCPTool } from "../../../client.js";
 
interface GetDocumentInput {
  documentId: string;
}
 
interface GetDocumentResponse {
  content: string;
}
 
/* Read a document from Google Drive */
export async function getDocument(input: GetDocumentInput): Promise<GetDocumentResponse> {
  return callMCPTool<GetDocumentResponse>('google_drive__get_document', input);
}

同じ処理をコードで書く — 文字起こしは context を通らない:

// Read transcript from Google Docs and add to Salesforce prospect
import * as gdrive from './servers/google-drive';
import * as salesforce from './servers/salesforce';
 
const transcript = (await gdrive.getDocument({ documentId: 'abc123' })).content;
await salesforce.updateRecord({
  objectType: 'SalesMeeting',
  recordId: '00Q5f000001abcXYZ',
  data: { Notes: transcript }
});

大きなデータの絞り込み:

// Without code execution - all rows flow through context
TOOL CALL: gdrive.getSheet(sheetId: 'abc123')
        → returns 10,000 rows in context to filter manually
 
// With code execution - filter in the execution environment
const allRows = await gdrive.getSheet({ sheetId: 'abc123' });
const pendingOrders = allRows.filter(row =>
  row["Status"] === 'pending'
);
console.log(`Found ${pendingOrders.length} pending orders`);
console.log(pendingOrders.slice(0, 5)); // Only log first 5 for review

制御フロー(ポーリング):

let found = false;
while (!found) {
  const messages = await slack.getChannelHistory({ channel: 'C123456' });
  found = messages.some(m => m.text.includes('deployment complete'));
  if (!found) await new Promise(r => setTimeout(r, 5000));
}
console.log('Deployment notification received');

PII をモデルに見せずに移送する:

const sheet = await gdrive.getSheet({ sheetId: 'abc123' });
for (const row of sheet.rows) {
  await salesforce.updateRecord({
    objectType: 'Lead',
    recordId: row.salesforceId,
    data: {
      Email: row.email,
      Phone: row.phone,
      Name: row.name
    }
  });
}
console.log(`Updated ${sheet.rows.length} leads`);

エージェントが(ログ出力したとしても)見えるのはこれだけ:

[
  { salesforceId: '00Q...', email: '[EMAIL_1]', phone: '[PHONE_1]', name: '[NAME_1]' },
  { salesforceId: '00Q...', email: '[EMAIL_2]', phone: '[PHONE_2]', name: '[NAME_2]' },
  ...
]

状態の永続化:

const leads = await salesforce.query({
  query: 'SELECT Id, Email FROM Lead LIMIT 1000'
});
const csvData = leads.map(l => `${l.Id},${l.Email}`).join('\n');
await fs.writeFile('./workspace/leads.csv', csvData);
 
// Later execution picks up where it left off
const saved = await fs.readFile('./workspace/leads.csv', 'utf-8');

再利用可能な skill として関数を保存する:

// In ./skills/save-sheet-as-csv.ts
import * as gdrive from './servers/google-drive';
export async function saveSheetAsCsv(sheetId: string) {
  const data = await gdrive.getSheet({ sheetId });
  const csv = data.map(row => row.join(',')).join('\n');
  await fs.writeFile(`./workspace/sheet-${sheetId}.csv`, csv);
  return `./workspace/sheet-${sheetId}.csv`;
}
 
// Later, in any agent execution:
import { saveSheetAsCsv } from './skills/save-sheet-as-csv';
const csvPath = await saveSheetAsCsv('abc123');

原典で言及されている関連文書

未取得の派生リンク