freee公式MCPサーバーのコードを開く。「APIをラップする」の実体は、宣言ではなく門番と配管だった
freee公式MCPサーバーのコードを開く。「APIをラップする」の実体は、宣言ではなく門番と配管だった
前の記事で、MCPを「APIをエージェントが読める形に包む規格」と整理した。ツール宣言(名前・説明・入出力スキーマ)だけが公開され、エージェントはそれを実行時に読む。生のAPIドキュメントを読ませる方式との差は能力ではなく、コスト・配管・契約・統制にある——そう書いた。
ただ、あの記事は概念の整理で、実物のコードは一行も見ていない。「ベンダーは実際どうやってAPIをMCPサーバーに料理しているのか」を確かめないまま先に進むのは気持ちが悪い。ちょうどよい教材があった。freeeが2026年3月にOSSとして公開した公式MCPサーバー→ freee-mcp(GitHub)だ。freeeは2018年からPublic APIを公開していて、人間の開発者向けのAPIリファレンスもある。同じ会社が同じAPIをMCP用に包み直したのだから、「ラップの前後」を見比べるにはこれ以上ない素材になる。
リポジトリをクローンして、Claude Codeのサブエージェント3体に読ませた。①OpenAPIからMCPツールへの変換部分、②認証やエラー処理の配管部分、③サーバー構成とAgent Skillsの部分、という分担だ。この記事は、その調査結果を私の理解の順番に並べ直したものになる。結論を先に言うと、前記事の理解は方向としては合っていたが、「ラップ」の中身は想像とだいぶ違った。
前提: freee-mcpの構成
freee-mcpはTypeScript製のOSS(Apache-2.0)で、npmパッケージとして配布されている。会計・人事労務・請求書・工数管理・販売・IT管理の6つのfreee APIを1つのサーバーで扱い、サイン(電子契約)だけは別コマンドに分かれている。接続方法は2つある。
- リモート版(推奨): freeeがホストするサーバー(
https://mcp.freee.co.jp/mcp)にURLを登録するだけで使える。前記事で「ストアに並ぶ税務アプリの実体は、ベンダーが運用するリモートのMCPサーバー」と書いた、まさにその形態 - ローカル版:
npx freee-mcpで自分のマシンに立てる。freeeへのアプリ登録とOAuth設定を自分でやる
リポジトリには本体のほかに、Dockerでのセルフホスト一式、Claude Code・Codex・Microsoft APMの3エコシステム向けプラグイン定義、そして後述するAgent Skillsが同梱されている。
予想が外れた: 277本のAPIに、ツールは15個
プレスリリースには「約270本のAPIを網羅的にMCPツール化」とある。前記事の理解のまま読むと、get_deals(取引一覧)、create_deal(取引作成)のようなツール宣言が270個並ぶ姿を想像する。エンドポイントごとに名前と説明とスキーマを付けて包む——「ラップ」という言葉から普通そう考える。
実物は違った。リポジトリ同梱のスキーマを集計すると、freeeのエンドポイントは一意パスで277本、メソッド単位で416操作ある。それに対して、MCPサーバーが公開するツールは15個(リモート版は13個)しかない。内訳はこうだ。
| 分類 | ツール | 役割 |
|---|---|---|
| API呼び出し | freee_api_get / freee_api_post / freee_api_put / freee_api_delete / freee_api_patch | HTTPメソッドごとの汎用ツール。パスは引数で渡す |
| API探索 | freee_api_list_paths | 呼べるパスの一覧を返す |
| 認証 | freee_authenticate / freee_auth_status / freee_clear_auth | OAuth認証の開始・確認・削除 |
| 事業所 | freee_set_current_company / freee_get_current_company / freee_list_companies | 複数事業所の切り替え |
| その他 | freee_current_user / freee_server_info / freee_file_upload | ユーザー情報・サーバー情報・証憑アップロード |
取引一覧を取るときエージェントが呼ぶのは、get_deals という専用ツールではなく freee_api_get {"service": "accounting", "path": "/api/1/deals", "query": {"limit": 10}} という汎用ツールだ。277本のエンドポイントは、5個のHTTP動詞ツールの引数空間に畳み込まれている。
そして、そのツール宣言の実物は驚くほど素っ気ない。GETツールの登録部分をそのまま引用する。
// src/openapi/client-mode.ts(抜粋)
registerTracedTool(
server,
'freee_api_get',
{
title: 'freee API GET リクエスト',
description: `freee API GET - ${SERVICE_HINT} (${SKILL_HINT})`,
// SERVICE_HINT = 'service: accounting/hr/invoice/pm/sm/it_management'
// SKILL_HINT = '詳細ガイドはfreee-api-skill skillを参照'
inputSchema: {
service: serviceSchema,
path: z.string().describe('APIパス (例: /api/1/deals)'),
query: coercibleRecord('クエリパラメータ (オプション)').optional(),
},
annotations: getHttpMethodToolAnnotations('GET'),
},
createMethodTool('GET'),
);
前記事で「ツール宣言は3点セット(名前・自然言語の説明・入出力スキーマ)」と書いた。その形式自体はこの通りなのだが、説明文は「freee API GET - service: accounting/hr/... (詳細ガイドはfreee-api-skill skillを参照)」という固定文字列で、取引APIの意味も給与APIの注意点も、ここには一文字も書かれていない。OpenAPIスキーマに入っている日本語のsummaryやdescriptionは、ツール宣言には一切使われていない。
では、あの277本ぶんの仕様情報はどこへ行ったのか。コードを追うと、行き先は2つあった。
行き先①: OpenAPIスキーマは「実行時の門番」になった
リポジトリの openapi/ ディレクトリには、freeeの各サービスのOpenAPIスキーマが丸ごと入っている。取得元は取得スクリプトにハードコードされていて、これが面白い。
// scripts/fetch-schemas.ts(抜粋)
const SCHEMA_SOURCES = [
{
name: "accounting-api",
url: "https://raw.githubusercontent.com/freee/freee-api-schema/master/v2020_06_15/open-api-3/api-schema.json",
...
},
{
name: "hr-api",
url: "https://raw.githubusercontent.com/freee/freee-api-schema/master/hr/open-api-3/api-schema.json",
...
},
// 請求書・販売はfreee-api-schema、工数管理・IT管理・サインは各サービスが自己公開する仕様から取得
];
取得元の → freee/freee-api-schema(GitHub) は、freeeが人間の開発者向けAPIリファレンスの大元として何年も前から公開してきたOpenAPI仕様そのものだ。前記事で「FastAPIのようなフレームワークのドキュメントページの裏にはOpenAPIという機械可読の仕様データがある」「MCPのツール宣言と情報としてはほぼ等価」と書いたが、freee-mcpはまさに人間向けリファレンスと同じ一次資料からMCPサーバーを組み立てている。仕様書が二重管理にならない構造が、取得URLの1行に表れている。
ただし、丸ごと使うわけではない。取得スクリプトは同時に minimizeSchema() という関数で軽量版を作り、サーバーが実行時に読むのは軽量版だけだ。何を残して何を捨てるかがはっきりしている。
- 残すもの: パスとメソッドの組み合わせ、summary・description、パス/クエリパラメータの名前と型、リクエストボディの有無フラグ
- 捨てるもの: レスポンスの型定義全体、データモデル定義(components)、パラメータのenum・pattern・example、タグ、operationId
さらに実行時のコードを追うと、軽量版に残したsummaryやparametersすら、実は読まれていない。サーバーがスキーマを参照するのはただ一箇所、エージェントが指定したパスが実在するかの検証だけだ。
// src/openapi/client-mode.ts(抜粋・freee_api_* 共通ハンドラ)
const validation = validatePathForService(method, path, service);
if (!validation.isValid) {
return createTextResponse(
`パス検証エラー: ${validation.message}\n\n` +
`利用可能なパスを確認するには freee_api_list_paths ツールを使用してください。`,
);
}
const result = await makeApiRequest(method, actualPath, query, body, validation.baseUrl, tokenContext);
つまりfreee-mcpにおけるOpenAPIスキーマの役割は、ツール宣言の材料ではなくルーティングテーブル兼門番だ。エージェントがパスを打ち間違えたり、存在しないエンドポイントをでっち上げたりしたら、freeeのサーバーに到達する前にMCPサーバーが差し戻す。差し戻しのメッセージには「一覧を確認するには freee_api_list_paths を使え」という次の行動の案内まで付いている。
検証の実装にも設計意図が刻まれている。パスの {id} のようなプレースホルダーを正規表現に変換する部分に、こういうコメントがある。
// src/openapi/schema-loader.ts(抜粋・コメントは原文の趣旨を要約)
// プレースホルダーを [^/]+ にすると '/api/1/deals/123?company_id=B' のような
// パス引数経由のクエリ密輸(query smuggling)を許してしまうため、?/#/&/= を除外する
const pattern = schemaPath.replace(/\{[^}]+\}/g, '[^/?#&=]+');
エージェントが(悪意なり事故なりで)パス文字列に別事業所のIDを紛れ込ませる、という攻撃面まで想定して門番が立っている。この層は、生のAPIドキュメントをエージェントに読ませる方式には存在しないものだ。
行き先②: 仕様の知識は「Agent Skills」に分離された
もう一つの行き先が、リポジトリに同梱された freee-api-skill というAgent Skillsだ。ビルド時スクリプトがフル版のOpenAPIスキーマをタグ(取引・経費申請・従業員……)ごとのMarkdownに変換し、リファレンス86ファイルを生成する。加えて、人間が手で書いた「レシピ」が15ファイルある。取引登録の正しい手順、工数登録の落とし穴、トラブルシューティングといった、単発のAPI仕様には収まらない業務手順の知識だ。
生成されたリファレンスの中身は、たとえば取引APIならこうなっている。
### GET /api/1/deals
操作: 取引(収入・支出)一覧の取得
| 名前 | 位置 | 必須 | 型 | 説明 |
|------|------|------|-----|------|
| company_id | query | はい | integer(int64) | 事業所ID |
| status | query | いいえ | string | 決済状況で絞込 (未決済: unsettled, 完了: settled) |
| type | query | いいえ | string | 収支区分 (収入: income, 支出: expense) |
...
人間向けAPIリファレンスに載っている情報と同じものが、エージェントがファイル検索で引ける形に変換されている。そしてスキルの案内文書には、役割分担がはっきり書いてある。Agent Skills = APIリファレンスと使い方ガイドの提供、MCPサーバー = 実際のAPI呼び出し。エージェントは取引APIを叩く前に references/accounting-deals.md を読み、パラメータの仕様を確認してから freee_api_get を呼ぶ、という流れだ。
この分離が解いている問題は、前記事で「コスト」と呼んだものの続きにある。仮に277本ぶんのツール宣言を全部並べると、エージェントは接続した瞬間にその全部をコンテキスト(作業記憶)に抱え込む。今日は取引を3件登録するだけでも、勤怠も年末調整も電子契約も、全ツールの説明文が毎回テキストとして流し込まれる。ツール宣言は生ドキュメントの通読より安いが、それでも数が増えれば同じ肥大化の道をたどる。freee-mcpの答えは、宣言は入口の15個だけに絞り、仕様の詳細は「必要になったときに必要な分だけファイルを読む」方式(progressive disclosure)に逃がすことだった。
つまり前記事で「MCPのツール宣言はAPIリファレンスのエージェント最適化版」と書いた対応関係は、freeeの実装ではこう分解されている。
| 前記事の概念 | freee-mcpでの実体 |
|---|---|
| ツール宣言(何ができるか) | 15個の汎用ツール+freee_api_list_paths が返すパス一覧 |
| 宣言の説明文(どう使うか) | Agent Skillsのリファレンス86本+レシピ15本 |
| 宣言と実装の整合性 | OpenAPIスキーマによる実行時パス検証 |
興味深い痕跡もあった。コードベースには「パスからツール名を生成する」関数(/api/1/deals/{id} を deals_by_id に変換する類のもの)が今も残っているが、本番コードからはどこからも呼ばれておらず、テストだけが参照している。エンドポイントごとの個別ツール化を試みた形跡と読めるが、これは推測の域を出ない。確かなのは、現行の設計が個別ツール化を採用していないという事実の方だ。
配管の中身: エージェントが見ずに済んでいるもの
前記事で「認証トークンの管理、レート制限、エラー時の再試行——この配管を毎回その場で組ませると事故る。MCPはここを標準化した」と書いた。freee-mcpのコードで、その配管の実物を確かめる。
認証。OAuth 2.0 + PKCEのフローが実装されていて、ローカル版では認可URLの生成からコールバック受信、トークン交換までをサーバーが面倒みる。取得したトークンには期限があるが、リフレッシュは「次のAPI呼び出しの直前に期限を確認して、切れていたら更新する」遅延評価方式で、エージェントは認証の存在をほぼ意識しない。実際のHTTPリクエストにヘッダーを付ける箇所はこうなっている。
// src/api/client.ts(抜粋)
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`, // トークンはサーバー内部で解決
'Content-Type': 'application/json',
'User-Agent': getUserAgent(), // freee-mcp/バージョンとtransport種別を名乗る
'freee-using-beta': 'true',
};
if (companyId) {
headers['x-freee-company-id'] = String(companyId);
}
リモート版はさらに徹底している。freee-mcpのサーバー自身がOAuth 2.1の認可サーバーとして振る舞い、エージェント側(Claude等)に渡すのはfreee-mcpが自前で発行したJWTだけ。freeeの本物のアクセストークンは、サーバーの内側(Redis)から一度も外に出ない。エージェントのコンテキストに秘密が載らない構造が、認可の設計そのものに組み込まれている。前記事で「公開されるもの/隠されるもの」の境界を論じたが、隠されるのは実装コードだけでなく、認証情報もこの層で遮断されている。
事業所の取り違え防止。freeeは1ユーザーが複数の事業所(顧問先)を持てる。エージェントがクエリやボディに現在の事業所と違う company_id を書いてきた場合、リクエストは組み立ての段階で拒否される。パス・クエリ・ボディの三箇所で整合性を検査していて、コメントには「tenant smuggling(テナント密輸)対策」と明記されている。会計事務所の感覚で言えば、A社の帳簿を開いているときにB社への書き込みが紛れ込むことを、プログラムの層で止めているということだ。これは前記事で論じた「解釈ミス」の一類型への、ベンダー側からの防御になる。
エラーの翻訳。freee APIが返す生のエラー(401/403/429)は、そのままエージェントには流されない。それぞれに日本語の対処ガイダンスが添えられる。認証切れなら「freee_authenticate ツールを使用して再認証を行ってください」、レート制限なら Retry-After ヘッダーを解析して「N秒後に再試行してください」、リクエスト不正なら「既存のデータを取得して正しい構造を確認することをお勧めします」。エラーメッセージの読み手がLLMであることを前提に、次に取るべき行動を文面に埋め込んでいる。
// src/openapi/client-mode.ts(抜粋・コメントは原文のまま)
// MCP 仕様 (Tools - Error Handling): 上流 API への呼び出しが 2xx 以外で返ってきた
// ケース(4xx/5xx/network/timeout 等)はツール実行失敗として `isError: true` で
// 返し、LLM/クライアントに成功と区別させる。
return createTextResponse(`APIリクエストエラー: ${formatErrorMessage(error)}`, {
isError: true,
});
一方で、自動リトライは実装されていない。429を受けたとき自動で待って再送する、という処理はコードのどこにもなく、「待て」という情報を整形して返すだけだ。再試行するかどうかの判断はエージェント側に残されている。配管がどこまでを引き受け、どこからを判断としてエージェントに返すか——その線引きも設計の一部として読み取れる。
成功時のレスポンスは加工なしの素通しで、freee APIのJSONが整形されてそのまま返る。フィールドの削減も要約もない。例外はPDFや画像などのバイナリで、MCPプロトコルの画像・リソース型に載せ替える形式変換だけが入る。
契約と統制: 宣言の外側で担保されるもの
前記事では「契約」を、ベンダーが実装とセットで保守する実行可能な仕様、と説明した。freee-mcpでの契約の実体は、ツール宣言そのものというよりスキーマ検証の門番だ。人間向けリファレンスと同じOpenAPIスキーマが検証テーブルとして動いているから、「ドキュメントにはあるのに実際は呼べない」「呼べるのにドキュメントにない」というズレが構造的に起きにくい。仕様の更新はスキーマの更新であり、それは即、門番の更新でもある。
「統制」の実装は2つ見つかった。一つはツール宣言に付くアノテーションだ。
// src/utils/http-method-annotations.ts(全文)
export function getHttpMethodToolAnnotations(method: HttpMethod): ToolAnnotations {
switch (method) {
case 'GET':
return { readOnlyHint: true };
case 'PUT':
case 'DELETE':
return { destructiveHint: true, idempotentHint: true };
case 'POST':
case 'PATCH':
return { destructiveHint: true };
}
}
readOnlyHint(読み取り専用)と destructiveHint(破壊的)はMCP仕様が定める標準のヒントで、前記事で書いた「読み取り系は自動許可、書き込み系は毎回確認」という権限制御は、まさにこのフラグを見てMCPクライアント(Claude Desktop等)が確認UIを出すことで成立する。注意すべきは分業の線引きで、freee-mcpのサーバー側には書き込みを止める確認プロンプトも読み取り専用モードも実装されていない。サーバーは正直にヒントを申告し、実際に人間へ確認を求めるのはクライアントの仕事、という役割分担だ。
もう一つはログで、これは前記事の「監査証跡」の論点にまっすぐつながる。リモート版は「1リクエスト = 1ログ行 = 1トレース」という方針で、どのユーザーが・どの事業所で・どのツールを呼び・その中でfreee APIを何回叩き・何が失敗したかを、1行の構造化ログに集約して記録する。OpenTelemetryでツール呼び出しごとの所要時間とエラー率も計測される。前記事で「呼び出し→取得→解釈→仕訳の連鎖を監査証跡として残す」ことを品質担保の柱に置いたが、その最初の2つ(呼び出しと取得の記録)は、ベンダー側のMCPサーバーに既に実装されはじめている。ログにはツール引数の中身(ボディやクエリの値)を残さない設計になっていて、会計データそのものが運用ログに漏れない配慮も同時に入っている。
ビフォー/アフターをシーケンス図で確かめる
ここまでの発見を、前記事の4論点(コスト・配管・契約・統制)に対応づけて図にする。まず、MCPなしでエージェントが生のAPIを直接叩く場合のやりとりから。
エージェントにはこれができる。できるが、仕様の読解(コスト)、認証とエラー対応の自作(配管)、間違えたときに仕様書と実装のどちらが悪いのか切り分けられない曖昧さ(契約)、読み取りと書き込みが同じ「生のHTTPリクエスト」で区別できない問題(統制)が、全部エージェント側に残る。同じことを、freee-mcpを挟んで実行するとこうなる。
2つの図の差分を、4論点で整理するとこうなる。
| 論点 | Before(生API直叩き) | After(freee-mcp経由) |
|---|---|---|
| コスト | 実行のたびに仕様書を通読・解析する | 宣言は15個。仕様はSkillから必要な分だけ読む |
| 配管 | OAuth・トークン更新・エラー対応を自作する | サーバー内で自動処理。秘密はエージェントに渡らない |
| 契約 | ドキュメントと実装のズレは自己責任で吸収 | 実装と同源のスキーマが実行前に検証する |
| 統制 | 読み書きの区別がなく、記録も残らない | 読み取り/破壊的の申告+1呼び出し1ログ行 |
前記事で「MCPはエージェントにできないことを可能にする規格ではなく、エージェントが毎回やらなくて済むようにする規格」と書いた。図2の左側の列(エージェントの自己処理)が、図3ではサーバー側の帯に移っている——この移動が、その一文のコードによる裏付けになる。
前記事の理解はどう更新されたか
コードを読む前と後で、私の理解は3箇所変わった。
「ラップ=宣言を書くこと」ではなかった。前記事はツール宣言を包み紙の表面と捉え、そこに書かれた説明の質がベンダーの品質だと書いた。freee-mcpの宣言は固定文字列で、意味の情報はほぼゼロだ。代わりに、仕様の知識はAgent Skillsへ、整合性の担保はスキーマ検証へ、と役割ごとに別の層に分かれていた。「説明文とスキーマの書き込みの丁寧さに品質が出る」という前記事の主張は、freeeの場合「リファレンスMarkdownとレシピの丁寧さに出る」と読み替える必要がある。実際、レシピ15本(取引登録の手順、工数登録の注意点など)は人間が業務知識を書き下ろしたもので、ここが一番人手のかかった部分に見える。
ツール宣言にもコストがあり、その先の設計が既に始まっていた。前記事は「生ドキュメントの通読 vs ツール宣言」の二項で書いたが、実務は「277個の宣言 vs 15個の宣言+必要時のファイル読み」まで進んでいた。エージェントのコンテキストは有限の資源で、ベンダーはその節約まで設計に織り込んでくる。MCPという規格の上で、さらに経済の工夫が積まれている。
ラッパーの本体は配管と統制だった。行数で見ても、このリポジトリの大部分はOAuthフロー、トークン保存、テナント整合性チェック、エラー翻訳、レート制限、ログとテレメトリに割かれている。「APIをラップする」という言葉から想像する変換処理(宣言の生成)は、拍子抜けするほど薄い。厚いのは、エージェントという新しい呼び出し主体を安全に受け入れるための防御と記録の層だ。前記事の言葉で言えば、コスト・配管・契約・統制のうち、コードの物量が費やされているのは圧倒的に配管と統制だった。
税理士としての読み方も一つ付け加えたい。freee-mcpには、エージェントが別事業所のIDを紛れ込ませたら機械的に拒否する検査があり、会計データをログに残さない運用設計があり、誰がどの事業所でどのAPIを叩いたかの記録がある。前記事で「この証跡を読んで検証する目が税理士・会計士の仕事の中心になる」と書いたが、ベンダー側の実装は思ったより先まで来ている。検証する側も、この層で何が守られていて何が守られていない(例: 書き込み確認はクライアント任せ、自動リトライはエージェント任せ)かを、具体的に知っておく必要がある。
まとめ
- freee公式MCPサーバー「freee-mcp」はOSSで、freeeが人間向けAPIリファレンスの大元として公開してきたOpenAPIスキーマと同じ一次資料から組み立てられている。仕様書の二重管理が起きない構造
- 277本のエンドポイントに対しツールは15個。HTTPメソッド単位の汎用ツール(
freee_api_get等)にパスを引数で渡す設計で、エンドポイントごとの個別ツールは作らない - OpenAPIスキーマはツール宣言の材料ではなく、実行時にパスの実在を検証する門番として働く。存在しないパスやクエリ密輸はfreeeのサーバーに届く前に差し戻される
- 仕様の詳細(パラメータ・レスポンス・業務手順)はAgent Skills(リファレンス86本+レシピ15本)に分離され、エージェントが必要な分だけ読む。ツール宣言の肥大化によるコンテキスト圧迫への回答
- 配管は厚い。OAuth+PKCE、トークンの自動更新、リモート版ではfreeeの実トークンをエージェントに一切見せないJWT間接化、事業所IDの三重整合性チェック、エラーの日本語ガイダンス化。一方で自動リトライはなく、判断はエージェントに返す
- 統制は分業になっている。サーバーは
readOnlyHint/destructiveHintを申告し、確認UIはクライアントが出す。リモート版は1呼び出し1ログ行の記録を残し、前記事の監査証跡の議論の入口が既に実装されている - 「MCPはAPIのラッパー」という理解は方向として正しいが、ラップの実体は宣言の生成ではなく、検証(門番)・知識の分離(Skill)・防御と記録(配管・統制)の3層だった