自作APIをMCP化するハンズオン。ミニ仕訳帳APIを作り、生のまま使い、包んでから使って比べる

未分類

自作APIをMCP化するハンズオン。ミニ仕訳帳APIを作り、生のまま使い、包んでから使って比べる

前の記事で、freee公式MCPサーバー「freee-mcp」のソースコードを読んだ。277本のエンドポイントに対してツールは15個、うちAPI呼び出しを担うのは5個の汎用ツールで、残りは認証や事業所切り替えなどの補助だった。OpenAPIスキーマは実行時の門番になり、仕様の知識はAgent Skillsに分離され、コードの大部分は認証とエラー整形と記録の配管だった。読み終えて、方向はわかった。ただ、他人のコードを読んだだけの理解は、自分で書いた理解より一段浅い。

そこで今回は、同じ構造のミニチュアを自分で作る。仕訳帳のAPIを1本自作し、まず生のままAIエージェントに使わせて、何が起きるかを見る。次にそのAPIをMCPサーバーで包み、同じ依頼をもう一度投げて、何が変わったかを見る。読むだけでは掴みきれなかった「包む」の意味を、手元のターミナルで確かめるハンズオンだ。

登場するコードは全文この記事に載せた。コマンドと出力はすべて私のPC(Windows 11)での実測値で、「たぶんこうなるはず」の出力は1行もない。所要時間は、コードを写経して動かすだけなら1時間程度、Before/Afterの実験まで再現するなら2時間程度だ。

前提は次のとおり。

  • Python 3.11 と → uv(Pythonのパッケージ管理ツール)。pipでも代替できる
  • コマンドはGit Bash(またはWSL等のPOSIXシェル)想定で書いてある。PowerShellで写経する場合は export$env: に読み替える
  • MCPクライアント。この記事ではClaude Codeを使う(Claude DesktopなどMCP対応クライアントなら流れは同じ)。インストールとログインは済ませておく。実測に使ったのは 2.1.208 で、実験のモデルを合わせたい場合は claude -p--model を付ける
  • ライブラリのバージョン: fastapi 0.139.0 / uvicorn 0.51.0 / mcp 1.28.1 / httpx 0.28.1 / pytest 9.1.1

プロジェクトの準備はこれだけでよい。

uv init journal-mcp-tutorial --python 3.11
cd journal-mcp-tutorial
uv add fastapi uvicorn httpx "mcp[cli]"
uv add --dev pytest

将来のバージョン差で挙動が変わったら、uv add "fastapi==0.139.0" "uvicorn==0.51.0" "mcp[cli]==1.28.1" "httpx==0.28.1" のように前提のバージョンへ固定すればよい。

この記事の三者関係を先に図にしておく。クラウドは出てこない。全部、読者のPCの中で完結する。

登場する三者は全部、読者のPCの中で動く。Claude CodeとMCPサーバー(server.py)はstdioで、MCPサーバーとミニ仕訳帳API(api.py)はHTTPでつながり、APIキーはMCPサーバーの環境変数にだけ置かれる
図1: 登場する三者は全部、読者のPCの中で動く。APIキーの置き場所がこの記事の伏線になる

Step 1: ミニ仕訳帳APIを作る

題材は会計ドメインで最小のもの、仕訳帳にした。扱うのは1行仕訳(借方1科目、貸方1科目)だけで、複合仕訳はスコープ外。エンドポイントは3本に絞る。

メソッドとパス内容
GET /journal-entries仕訳一覧(month=YYYY-MM で絞り込み)
POST /journal-entries仕訳の登録
GET /trial-balance勘定科目別の残高試算表

勘定科目は7科目のホワイトリスト制にする。現金、普通預金(以上、資産)、買掛金(負債)、資本金(純資産)、売上高(収益)、仕入高、消耗品費(以上、費用)。リストにない科目は422エラーで弾く。認証は X-API-Key ヘッダーで、キーを持たない呼び出しは401で拒否する。この「キーを持つ者しか叩けない」という性質が、後半の実験で効いてくる。

残高試算表には、会計の人向けの設計を1つ入れた。科目ごとに**残高の側(balance_side)正常残高側(normal_side)**を別の属性として返す。前者は「借方合計 − 貸方合計」の符号で決まる実際の残高の側、後者は科目の区分から決まる性質(資産と費用は借方、負債と純資産と収益は貸方)だ。2つは独立に決まるから、食い違ったとき(現金なのに貸方残、つまり残高がマイナス)に is_abnormal: true を機械的に立てられる。

api.py の全文がこれだ。

"""ミニ仕訳帳API — FastAPI製の教材用API。

freee Public APIの1/100模型。1行仕訳(借方1・貸方1)だけを扱う。複合仕訳はスコープ外。
起動: uv run uvicorn api:app
"""

import datetime as dt

from fastapi import Depends, FastAPI, Header, HTTPException, Query
from pydantic import BaseModel, Field, field_validator

# 教材用の固定キー。実務では環境変数や秘密管理サービスに置く
API_KEY = "sk-journal-tutorial-2026"

# 勘定科目ホワイトリスト(科目 → 区分)
ACCOUNTS: dict[str, str] = {
    "現金": "資産",
    "普通預金": "資産",
    "買掛金": "負債",
    "資本金": "純資産",
    "売上高": "収益",
    "仕入高": "費用",
    "消耗品費": "費用",
}

# 区分 → 残高の正常な側
NORMAL_SIDE: dict[str, str] = {
    "資産": "借方",
    "費用": "借方",
    "負債": "貸方",
    "純資産": "貸方",
    "収益": "貸方",
}


class JournalEntryIn(BaseModel):
    """仕訳の入力。1行仕訳(借方1・貸方1)のみ。"""

    date: dt.date
    debit_account: str
    credit_account: str
    amount: int = Field(gt=0, description="金額(正の整数・円)")
    description: str = ""

    @field_validator("debit_account", "credit_account")
    @classmethod
    def account_must_be_known(cls, v: str) -> str:
        if v not in ACCOUNTS:
            allowed = "".join(ACCOUNTS)
            raise ValueError(f"勘定科目「{v}」は使えません。使える科目: {allowed}")
        return v


class JournalEntry(JournalEntryIn):
    id: int


def seed_entries() -> list[JournalEntry]:
    rows = [
        ("2026-07-01", "現金", "資本金", 100_000, "開業出資"),
        ("2026-07-05", "仕入高", "現金", 30_000, "商品仕入"),
        ("2026-07-10", "現金", "売上高", 80_000, "売上計上"),
        ("2026-07-12", "消耗品費", "現金", 5_000, "事務用品"),
        ("2026-07-20", "普通預金", "現金", 50_000, "預け入れ"),
    ]
    return [
        JournalEntry(
            id=i, date=d, debit_account=dr, credit_account=cr, amount=a, description=desc
        )
        for i, (d, dr, cr, a, desc) in enumerate(rows, start=1)
    ]


ENTRIES: list[JournalEntry] = seed_entries()


def build_trial_balance(entries: list[JournalEntry]) -> dict:
    """残高試算表を組み立てる純粋関数。

    balance_side は「借方合計 − 貸方合計」の符号で決める(正→借方、負→貸方、0→残高なし)。
    normal_side は区分から決まる正常残高側で、両者が食い違えば is_abnormal を立てる。
    """
    accounts = []
    for account, category in ACCOUNTS.items():
        debit_total = sum(e.amount for e in entries if e.debit_account == account)
        credit_total = sum(e.amount for e in entries if e.credit_account == account)
        if debit_total == 0 and credit_total == 0:
            continue  # 動きのない科目は載せない
        net = debit_total - credit_total
        balance_side = "借方" if net > 0 else "貸方" if net < 0 else None
        normal_side = NORMAL_SIDE[category]
        accounts.append(
            {
                "account": account,
                "category": category,
                "debit_total": debit_total,
                "credit_total": credit_total,
                "balance_side": balance_side,
                "balance": abs(net),
                "normal_side": normal_side,
                "is_abnormal": balance_side is not None and balance_side != normal_side,
            }
        )
    debit_sum = sum(a["debit_total"] for a in accounts)
    credit_sum = sum(a["credit_total"] for a in accounts)
    return {
        "accounts": accounts,
        "total": {
            "debit_total": debit_sum,
            "credit_total": credit_sum,
            "in_balance": debit_sum == credit_sum,
        },
    }


def require_api_key(x_api_key: str | None = Header(default=None)) -> None:
    if x_api_key != API_KEY:
        raise HTTPException(
            status_code=401, detail="APIキーが不正です。X-API-Key ヘッダーを確認してください。"
        )


app = FastAPI(
    title="ミニ仕訳帳API",
    description="教材用のミニ仕訳帳。1行仕訳の登録・一覧・残高試算表だけができる。",
    version="0.1.0",
    dependencies=[Depends(require_api_key)],
)


@app.get("/journal-entries")
def list_journal_entries(
    month: str | None = Query(default=None, pattern=r"^\d{4}-\d{2}$"),
) -> list[JournalEntry]:
    """仕訳一覧。month=YYYY-MM で対象月に絞り込める。"""
    if month is None:
        return ENTRIES
    return [e for e in ENTRIES if e.date.strftime("%Y-%m") == month]


@app.post("/journal-entries", status_code=201)
def register_journal_entry(entry: JournalEntryIn) -> JournalEntry:
    """仕訳を1件登録する。"""
    next_id = max((e.id for e in ENTRIES), default=0) + 1
    stored = JournalEntry(id=next_id, **entry.model_dump())
    ENTRIES.append(stored)
    return stored


@app.get("/trial-balance")
def get_trial_balance() -> dict:
    """勘定科目別の残高試算表。"""
    return build_trial_balance(ENTRIES)

保存はインメモリで、シード仕訳を5件入れてある。開業出資10万円、仕入3万円、売上8万円、消耗品5千円、預け入れ5万円。この5件に対する残高試算表は手で計算できて、次の表になるはずだ。

科目借方合計貸方合計残高
現金180,00085,000借方 95,000
普通預金50,0000借方 50,000
仕入高30,0000借方 30,000
消耗品費5,0000借方 5,000
売上高080,000貸方 80,000
資本金0100,000貸方 100,000
合計265,000265,000借方残計 180,000 = 貸方残計 180,000

「はずだ」で終わらせず、この期待値表をそのままテストに固定する。貸借一致(借方合計=貸方合計)と残高の一致(借方残計=貸方残計)は複式簿記の恒等式なので、目視レビューではなくテストコードに検証させるのが確実だ。おまけに、現金の残高を超える支払いを登録したら is_abnormal が立つことも確かめる。test_api.py の全文はこうなる。

"""ミニ仕訳帳APIのテスト。

シード5件に対する残高試算表の期待値表をそのまま固定し、
貸借一致・異常残高の検知までを自動で確かめる。
実行: uv run pytest -q
"""

import pytest
from fastapi.testclient import TestClient

import api

client = TestClient(api.app)
HEADERS = {"X-API-Key": api.API_KEY}


@pytest.fixture(autouse=True)
def reset_entries():
    """各テストの前にシード5件の状態へ戻す(テスト間の独立性を保つ)。"""
    api.ENTRIES[:] = api.seed_entries()
    yield


# ---- 期待値表(シード5件に対する残高試算表) ----
EXPECTED_ROWS = {
    "現金": {"debit_total": 180_000, "credit_total": 85_000, "balance_side": "借方", "balance": 95_000},
    "普通預金": {"debit_total": 50_000, "credit_total": 0, "balance_side": "借方", "balance": 50_000},
    "仕入高": {"debit_total": 30_000, "credit_total": 0, "balance_side": "借方", "balance": 30_000},
    "消耗品費": {"debit_total": 5_000, "credit_total": 0, "balance_side": "借方", "balance": 5_000},
    "売上高": {"debit_total": 0, "credit_total": 80_000, "balance_side": "貸方", "balance": 80_000},
    "資本金": {"debit_total": 0, "credit_total": 100_000, "balance_side": "貸方", "balance": 100_000},
}


def get_trial_balance() -> dict:
    res = client.get("/trial-balance", headers=HEADERS)
    assert res.status_code == 200
    return res.json()


def test_trial_balance_matches_expected_table():
    """①期待値表そのもの(全科目・is_abnormalはすべてfalse)。"""
    tb = get_trial_balance()
    rows = {a["account"]: a for a in tb["accounts"]}
    assert set(rows) == set(EXPECTED_ROWS)
    for account, expected in EXPECTED_ROWS.items():
        actual = rows[account]
        for key, value in expected.items():
            assert actual[key] == value, f"{account}.{key}: {actual[key]} != {value}"
        assert actual["is_abnormal"] is False, f"{account} が異常残高扱いになっている"


def test_debit_total_equals_credit_total():
    """②借方合計=貸方合計(265,000円)。"""
    total = get_trial_balance()["total"]
    assert total["debit_total"] == 265_000
    assert total["credit_total"] == 265_000
    assert total["in_balance"] is True


def test_debit_balances_equal_credit_balances():
    """③借方残の合計=貸方残の合計(180,000円)。"""
    accounts = get_trial_balance()["accounts"]
    debit_balances = sum(a["balance"] for a in accounts if a["balance_side"] == "借方")
    credit_balances = sum(a["balance"] for a in accounts if a["balance_side"] == "貸方")
    assert debit_balances == 180_000
    assert credit_balances == 180_000


def test_abnormal_balance_is_detected():
    """④現金がマイナスになる仕訳を追加すると is_abnormal: true が立つ。"""
    res = client.post(
        "/journal-entries",
        headers=HEADERS,
        json={
            "date": "2026-07-31",
            "debit_account": "仕入高",
            "credit_account": "現金",
            "amount": 200_000,
            "description": "残高を超える仕入(異常検知テスト)",
        },
    )
    assert res.status_code == 201
    cash = next(a for a in get_trial_balance()["accounts"] if a["account"] == "現金")
    assert cash["balance_side"] == "貸方"  # 資産なのに貸方残
    assert cash["normal_side"] == "借方"
    assert cash["is_abnormal"] is True


def test_register_and_list():
    """正常登録(201)と一覧・month絞り込み。"""
    res = client.post(
        "/journal-entries",
        headers=HEADERS,
        json={
            "date": "2026-08-01",
            "debit_account": "消耗品費",
            "credit_account": "現金",
            "amount": 3_300,
            "description": "コピー用紙",
        },
    )
    assert res.status_code == 201
    assert res.json()["id"] == 6

    all_entries = client.get("/journal-entries", headers=HEADERS).json()
    assert len(all_entries) == 6
    july = client.get("/journal-entries", params={"month": "2026-07"}, headers=HEADERS).json()
    assert len(july) == 5
    august = client.get("/journal-entries", params={"month": "2026-08"}, headers=HEADERS).json()
    assert len(august) == 1


def test_missing_or_wrong_api_key_returns_401():
    assert client.get("/journal-entries").status_code == 401
    assert client.get("/journal-entries", headers={"X-API-Key": "wrong"}).status_code == 401


@pytest.mark.parametrize(
    "patch",
    [
        {"debit_account": "雑費"},  # ホワイトリスト外の科目
        {"amount": 0},  # 正の整数でない
        {"amount": -100},
        {"date": "2026/07/25"},  # YYYY-MM-DD でない
    ],
)
def test_invalid_input_returns_422(patch):
    body = {
        "date": "2026-07-25",
        "debit_account": "消耗品費",
        "credit_account": "現金",
        "amount": 3_300,
        "description": "バリデーションテスト",
    } | patch
    res = client.post("/journal-entries", headers=HEADERS, json=body)
    assert res.status_code == 422


def test_unknown_account_error_lists_allowed_accounts():
    """422のエラー文に「使える科目」の案内が入る(エラー整形の伏線)。"""
    res = client.post(
        "/journal-entries",
        headers=HEADERS,
        json={
            "date": "2026-07-26",
            "debit_account": "雑費",
            "credit_account": "現金",
            "amount": 1_200,
            "description": "エラー系の固定依頼文と同じ内容",
        },
    )
    assert res.status_code == 422
    message = res.json()["detail"][0]["msg"]
    assert "雑費" in message
    assert "現金" in message  # 使える科目一覧が含まれる

TestClient はAPIをインプロセスで叩くので、サーバーの起動なしにテストが走る。実行結果はこうだった。

$ uv run pytest -q
...........
11 passed, 1 warning in 1.29s

テストが通ったら、サーバーを起動してブラウザで開く。

uv run uvicorn api:app
# INFO:  Uvicorn running on http://127.0.0.1:8000

このターミナルはサーバーが占有するので、以降のcurlやclaudeは別のターミナルから叩く。

http://127.0.0.1:8000/docs を開くと、書いた覚えのないAPIリファレンス画面(Swagger UI)が生えている。

ミニ仕訳帳APIのSwagger UI。GET /journal-entries、POST /journal-entries、GET /trial-balanceの3エンドポイントと、それぞれのパラメータとスキーマが自動生成されている
図2: /docs に自動で生えるSwagger UI。裏では /openapi.json が機械可読の仕様を配っている

これはFastAPIが、コードのPydanticモデルからOpenAPIスキーマ(/openapi.json)を自動生成し、その上に人間向けの画面をかぶせたものだ。前の記事で見たとおり、freeeの人間向けAPIリファレンスも大元は同じOpenAPIスキーマで、freee-mcpはそれを実行時の門番として使っていた。いま手元の3エンドポイントで起きたことは、freeeの277本で起きていることの、おおよそ1/100の模型になっている。

curlでも叩いておく。認証が効いていること、期待値表どおりの試算表が返ることの確認だ。

$ curl -s -i http://127.0.0.1:8000/journal-entries
HTTP/1.1 401 Unauthorized
(ヘッダー略)
{"detail":"APIキーが不正です。X-API-Key ヘッダーを確認してください。"}

$ curl -s -H "X-API-Key: sk-journal-tutorial-2026" http://127.0.0.1:8000/trial-balance
{"accounts":[{"account":"現金","category":"資産","debit_total":180000,"credit_total":85000,
"balance_side":"借方","balance":95000,"normal_side":"借方","is_abnormal":false},
(中略: 期待値表と同じ6科目が続く)
],"total":{"debit_total":265000,"credit_total":265000,"in_balance":true}}

登録(POST)はWindowsだと1つ罠がある。日本語入りのJSONをコマンドラインの -d '{...}' で渡すと、Git Bashからcurl.exeへの引数変換で文字が壊れ、400(body parse error)になる。後のStep 2の実験でも、エージェントが同じ箇所で2回とも400を踏んでいる。ボディをファイルに保存して渡せば通る。逆に、レスポンスの日本語が化けて見えるときはコンソールの文字コードによる表示側の問題で、格納データは無事なことが多い。

$ printf '%s' '{"date":"2026-07-25","debit_account":"消耗品費","credit_account":"現金","amount":3300,"description":"コピー用紙"}' > body.json
$ curl -s -X POST -H "X-API-Key: sk-journal-tutorial-2026" -H "Content-Type: application/json" --data @body.json http://127.0.0.1:8000/journal-entries
{"date":"2026-07-25","debit_account":"消耗品費","credit_account":"現金","amount":3300,"description":"コピー用紙","id":6}

ホワイトリスト外の科目は、バリデータが書いたガイダンス付きの422で返る。

$ printf '%s' '{"date":"2026-07-26","debit_account":"雑費","credit_account":"現金","amount":1200,"description":"来客用お茶"}' > body2.json
$ curl -s -X POST -H "X-API-Key: sk-journal-tutorial-2026" -H "Content-Type: application/json" --data @body2.json http://127.0.0.1:8000/journal-entries
{"detail":[{"type":"value_error","loc":["body","debit_account"],
"msg":"Value error, 勘定科目「雑費」は使えません。使える科目: 現金、普通預金、買掛金、資本金、売上高、仕入高、消耗品費",
"input":"雑費","ctx":{"error":{}}}]}

これでAPIは完成だ。人間の開発者としては、/docs を見ながらこのAPIを使える。次の問いは「AIエージェントはこれをどう使うか」になる。

Step 2: 生のまま使わせてみる(Before体験)

MCPを持ち出す前に、生のAPIをそのままエージェントに使わせて、何が起きるかを記録しておく。あとでMCP経由と比べるので、条件を固定した統制実験にする。

  • 依頼文を固定する。正常系「2026-07-25付で、消耗品費 3,300円を現金で支払った仕訳を登録して。」とエラー系「2026-07-26付で、雑費 1,200円を現金で支払った仕訳を登録して。」の2本。雑費はホワイトリスト外なので、後者は必ず422を踏む
  • 毎回シード状態に戻す。各実験の前にAPIを再起動して、5件のシード仕訳だけの状態から始める
  • キーは会話の外で渡す。環境変数 JOURNAL_API_KEY にキーを設定した上で、「キーは環境変数にある。値を会話やコマンドの出力に書き出さないで」と依頼に含める。会話にキーを貼り付ける比較は生API側に不公平なので、やらない

観測するのは3点。①キーの扱い(値が会話に出たか)②承認の粒度(何に対して許可を出すことになるか)③エラーからの復旧(422を受けたときの挙動)だ。

実験のエージェントにはClaude Code(モデルはclaude-fable-5)を使い、実録を残すためにヘッドレスモードで走らせた。読者が再現するときは、対話モードのClaude Codeで同じ依頼文を打てばよい。ただしエージェントの挙動は確率的で、ターン数や途中の調査手順は実行ごとに変わる(この記事の検証でも、同じ依頼がターン数7で完走した回がある)。再現されるのは逐一のログではなく、仕様の読解やエラーの調査がエージェント側の仕事として毎回積まれる、という構造の方だ。実測に使ったコマンドはこれだ(--strict-mcp-config はMCPサーバーを一切読み込まない指定で、Before側にMCPを混ぜないための統制。--allowedTools "Bash" はシェルコマンドの実行だけを許可する)。

export JOURNAL_API_KEY="sk-journal-tutorial-2026"
claude -p '(前置き+依頼文)' --allowedTools "Bash" --strict-mcp-config --output-format stream-json --verbose

長い日本語プロンプトをシングルクォートに詰めるのが面倒なら、前置きと依頼文をまとめて prompt.txt に保存し、claude -p "$(cat prompt.txt)" と渡してもよい。また、--output-format stream-json --verbose の出力は1行1イベントの生JSONで流れてくる。以下に載せる実録は、そこからツール呼び出しの行を抜粋して整形したものだ。

依頼文の前には、APIの場所を伝える前置きを付けた。全文を載せる。

http://127.0.0.1:8000 でミニ仕訳帳APIが動いている。APIリファレンスは http://127.0.0.1:8000/docs(機械可読版は /openapi.json)。認証は X-API-Key ヘッダーで、キーは環境変数 JOURNAL_API_KEY に入っている。キーの値を会話やコマンドの出力に書き出さないこと。

依頼: 2026-07-25付で、消耗品費 3,300円を現金で支払った仕訳を登録して。

正常系の実録: 6ターンの内訳

エージェントの動きを実録から抜き出す。

[1] curl -s http://127.0.0.1:8000/openapi.json
    → OpenAPIスキーマ全体を取得して読解
[2] curl -X POST http://127.0.0.1:8000/journal-entries \
      -H "X-API-Key: $JOURNAL_API_KEY" -H "Content-Type: application/json" \
      -d '{"date":"2026-07-25","debit_account":"消耗品費",...}'
    → {"detail":"There was an error parsing the body"}(HTTP 400)
[3] .tmp-entry.json を作成(JSONボディをファイルに退避)
[4] curl -X POST ... --data-binary @.tmp-entry.json
    → {"date":"2026-07-25",...,"id":6}(HTTP 201)
[5] rm .tmp-entry.json

登録には成功した。ターン数は6。上の抜粋のツール呼び出し5手に、最後の報告を加えた往復数で、以下この数え方で書く。挙動として記録しておきたい点が3つある。

第一に、エージェントは仕様の読解から始めた。/openapi.json を丸ごと取得して、エンドポイントとスキーマを解析してからPOSTを組み立てている。仕様を読む作業が毎回の実行時間に乗る、というのが生API方式の構造だ。

第二に、Step 1で私が踏んだWindowsのエンコーディング問題を、エージェントも同じように踏んだ。日本語入りJSONをインラインの -d で送って400を受け、ファイル退避のワークアラウンドに切り替えて成功している。回復できたのは立派だが、この2手はエージェントがシェルのコマンドラインで引数を組み立てているから発生している。

第三に、キーの扱いは正しかった。全コマンドで $JOURNAL_API_KEY の変数参照を使い、値そのものは実録のどこにも出現しなかった(実録ファイルをキー文字列でgrepして0件)。ただし、この正しさは「エージェントが毎回の生成でそう振る舞ったから」成立している点は覚えておいてほしい。次の実験でそこが崩れる。

エラー系の実録: 422を受けたエージェントは何をしたか

同じ前置きで、雑費の依頼を投げる。前半は正常系と同じ(openapi.json読解、400、ファイル退避)で、そこから先がこうなった(ターン数は7)。

[4] curl -X POST ... --data-binary @.tmp-entry.json
    → 422 {"msg":"Value error, 勘定科目「雑費」は使えません。
       使える科目: 現金、普通預金、買掛金、資本金、売上高、仕入高、消耗品費",...}
[5] Grep pattern="雑費|消耗品費|ALLOWED|勘定科目" path=api.py -C 3
    → api.py のソースコードからホワイトリスト定義を発見
       (このgrep結果の中に API_KEY = "sk-journal-tutorial-2026" の行が含まれた)
[6] 登録せず停止。「消耗品費として登録する/api.pyに雑費を追加する」の
    2択をユーザーに提示。「会計記録の科目を私の判断で勝手に置き換えるべきではない」

最終的な判断は良かった。勝手に科目を読み替えず、選択肢を提示して人間に確認を求めている。会計記録に対するエージェントの振る舞いとしては満点だ。

問題は手順5で起きた。422の原因を調べるために、エージェントはAPIのソースコード api.py をgrepした。その結果の前後3行に、ハードコードされたAPIキーの定義行が含まれていた。キーの値が、エージェントの会話コンテキストに転がり込んだ

これは「エージェントがキーをechoした」のではない。依頼どおり、キーを書き出そうとはしていない。原因調査でソースファイルを覗いたら、そこにキーが書いてあった、という事故だ。教材だからキーを api.py に直書きしたのだが、皮肉なことに、その手抜きが「秘密は置き場所の構造で守るしかない」ことの実演になった。エージェントの善意や注意力は、たまたま通りかかったファイルの中身までは制御できない。

前の記事に描いた「生API直叩き」のシーケンス図の世界そのものだ。仕様の読解も、エンコーディング事故の復旧も、エラーの原因調査も、全部エージェントの仕事として毎回積まれる。そして秘密の管理は、エージェントの毎回の振る舞いに依存する。

Step 3: MCPサーバーで包む

ここからが本題だ。いま作ったAPIを、MCP公式Python SDK(→ modelcontextprotocol/python-sdk(GitHub))の FastMCP で包む。エンドポイント3本をツール3本に1対1で対応させる。freee-mcpは277本を5個の汎用ツールに畳み込んでいたが、あれは277本あるからの設計で、3本なら1対1が素直だ(この分岐の話は最後の節でもう一度出てくる)。なお、freee-mcpの門番(validatePathForServiceによるパスの実在検証)に相当する部品は今回は登場しない。パスはserver.pyのコードに固定されていて、エージェントがパス文字列を渡す余地自体がないからだ。

server.py は4つの部品でできている。部品ごとに、freee-mcpのどの実装の縮小版かを添えて示す。

部品1: 設定とログ。APIキーと接続先は環境変数から読む。ツール呼び出しの記録はJSONLで1呼び出し1行。

"""ミニ仕訳帳APIを包むMCPサーバー — freee-mcpの縮小再現。

公式Python SDK(mcp)のFastMCPを使い、stdioトランスポートで動く。
起動: uv run server.py(実際はMCPクライアントがサブプロセスとして起動する)
"""

import datetime as dt
import json
import os
from pathlib import Path
from typing import Any

import httpx
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations

# 資格情報と接続先は環境変数から読む。キーはツールの宣言にも引数にも現れない。
# freee-mcp対応: OAuthトークンをサーバー内部で解決してBearerヘッダーに付ける(src/api/client.ts)
API_KEY = os.environ.get("JOURNAL_API_KEY", "")
BASE_URL = os.environ.get("JOURNAL_API_BASE_URL", "http://127.0.0.1:8000")

# 使える勘定科目(api.pyのホワイトリストと同じ7科目)。
# freee-mcpはこの種の仕様知識をOpenAPIスキーマとAgent Skillsから引くが、ツール3本なら手書きが素直
ACCOUNTS = "現金、普通預金、買掛金、資本金、売上高、仕入高、消耗品費"

LOG_PATH = Path(__file__).with_name("journal_mcp.log")

mcp = FastMCP("journal")


def write_log(tool: str, args: dict[str, Any], status: str) -> None:
    """ツール呼び出し1回=JSONL1行の操作ログ。

    freee-mcp対応: リモート版の「1リクエスト=1ログ行」(canonical log line)の縮小版。
    ただしこれは監査証跡ではない——実行者の識別・改ざん検知・API側記録との突合がなく、
    ただのテキストファイルなのでいくらでも書き換えられる。
    """
    record = {
        "time": dt.datetime.now().isoformat(timespec="seconds"),
        "tool": tool,
        "args": args,
        "status": status,
    }
    with LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")

環境変数からキーを読む1行が、Before体験で起きた事故への答えになる。キーを扱うコードがこのファイルに集約され、後で見るとおり、ツールの宣言にも引数にもキーというフィールドは存在しなくなる。会話にキーを書く必要が、構造として消える。freee-mcpのリモート版はさらに徹底していて、freeeの実トークンをサーバー内部の保管場所から一度も外に出さない。方向は同じで、規模が違うだけだ。

ここで正確を期しておくと、MCPという仕様が「キーは環境変数で管理せよ」と定めているわけではない。今回のサーバー実装でそう作った、という話であり、構造として言えるのは「キーを扱うコードがサーバー側に集約され、ツールの引数にキーが現れない設計が自然になる」ところまでだ。

ログの方は、freee-mcpリモート版の「1リクエスト=1ログ行」の縮小版だが、コメントに書いたとおり、これを監査証跡と呼んではいけない。誰が実行したかの識別がなく、改ざんを検知できず、API側の記録との突合もない、ただの編集可能なテキストファイルだ。会計の人向けに言えば、現金出納帳を鉛筆で書いているようなもので、記録があることと証跡であることの間には距離がある。freee-mcpの「1リクエスト=1ログ行」でも、この距離がゼロになるわけではない。監査証跡の議論は前の記事の統制の節に書いたので、ここでは「操作ログ」という呼び名を守ることだけ決めておく。

部品2: エラーの整形。FastAPIの422をLLM向けのガイダンス文に翻訳する。

def format_422(detail: list[dict]) -> str:
    """FastAPIのバリデーションエラーを読み下し、次の行動を促す文に変える。

    freee-mcp対応: 生の401/403/429に日本語の対処ガイダンスを添えるエラー整形
    (src/openapi/client-mode.ts の formatErrorMessage 系)の縮小版。
    """
    lines = [
        f"- {'.'.join(str(x) for x in item.get('loc', [])[1:])}: {item.get('msg', '')}"
        for item in detail
    ]
    return (
        "入力エラーで受け付けられませんでした。\n"
        + "\n".join(lines)
        + f"\n使える勘定科目: {ACCOUNTS}。日付は YYYY-MM-DD、金額は正の整数(円)です。"
    )

部品3: API呼び出しの共通経路。httpxでキーを付けて呼び、401と422をガイダンス付きの ToolError に変換する。ToolError を投げると、MCPクライアントには isError: true のツール実行失敗として届き、成功と明確に区別される。

def call_api(
    tool: str,
    method: str,
    path: str,
    *,
    params: dict | None = None,
    json_body: dict | None = None,
) -> Any:
    """APIを呼び、エラーをエージェント向けのガイダンス文に整形する共通経路。

    ToolErrorを投げると、MCPクライアントには isError: true のツール実行失敗として返る
    (成功と区別されるので、エージェントが「失敗した」と認識してリカバリーに動ける)。
    """
    args = {k: v for k, v in {"params": params, "body": json_body}.items() if v}
    try:
        res = httpx.request(
            method,
            BASE_URL + path,
            headers={"X-API-Key": API_KEY},
            params=params,
            json=json_body,
            timeout=10.0,
        )
    except httpx.ConnectError as e:
        write_log(tool, args, "connect_error")
        raise ToolError(
            f"APIに接続できません({BASE_URL})。"
            "`uv run uvicorn api:app` でミニ仕訳帳APIが起動しているか確認してください。"
        ) from e
    if res.status_code == 401:
        write_log(tool, args, "401")
        raise ToolError(
            "APIキーが違います。MCPサーバーの環境変数 JOURNAL_API_KEY を確認してください。"
        )
    if res.status_code == 422:
        write_log(tool, args, "422")
        raise ToolError(format_422(res.json().get("detail", [])))
    res.raise_for_status()  # 予期しないステータスはそのままツール実行失敗として上げる
    write_log(tool, args, str(res.status_code))
    return res.json()

エラー文面の設計は、freee-mcpから学んだ「読み手はLLMである」という前提をそのまま持ち込んだ。認証切れなら確認すべき環境変数の名前を、入力エラーなら使える科目の一覧を、失敗の報告と同じ文面に埋め込む。エージェントに「次に何をすべきか」を推測させない。

部品4: ツール3本の宣言。docstringがそのままツールのdescriptionになる。

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def list_journal_entries(month: str | None = None) -> list[dict]:
    """仕訳の一覧を取得する。

    month に "YYYY-MM"(例: "2026-07")を渡すと、その月の仕訳だけに絞り込む。
    省略するとすべての仕訳を返す。各仕訳は id・date・debit_account(借方科目)・
    credit_account(貸方科目)・amount(金額・円)・description(摘要)を持つ。
    """
    params = {"month": month} if month else None
    return call_api("list_journal_entries", "GET", "/journal-entries", params=params)


@mcp.tool(
    # 仕訳の新規登録は「加算的」で、既存データを書き換えも削除もしないので
    # destructiveHint は付けない。freee-mcp対応: http-method-annotations.ts は
    # POSTを一律 destructiveHint: true にする(HTTPメソッドから機械導出する保守的設計)。
    # ツールを手で書くなら、操作の意味に即して申告できる。
    # なお annotations は認可を強制しない「クライアント向けのヒント」にすぎない(MCP仕様)。
    annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False),
)
def register_journal_entry(
    date: str,
    debit_account: str,
    credit_account: str,
    amount: int,
    description: str = "",
) -> dict:
    """仕訳を1件登録する(1行仕訳のみ。借方1科目・貸方1科目)。

    date は "YYYY-MM-DD" 形式、amount は正の整数(円)。
    勘定科目はホワイトリスト制で、使えるのは次の7科目だけ:
    現金、普通預金、買掛金、資本金、売上高、仕入高、消耗品費。
    この一覧にない科目(例: 雑費)を渡すと入力エラーになる。
    """
    body = {
        "date": date,
        "debit_account": debit_account,
        "credit_account": credit_account,
        "amount": amount,
        "description": description,
    }
    return call_api("register_journal_entry", "POST", "/journal-entries", json_body=body)


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def get_trial_balance() -> dict:
    """勘定科目別の残高試算表を取得する。

    科目ごとに借方合計(debit_total)・貸方合計(credit_total)・残高(balance)・
    残高の側(balance_side)・区分から決まる正常残高側(normal_side)を返す。
    両者が食い違う科目には is_abnormal: true が立つ(例: 現金なのに貸方残)。
    末尾の total で借方合計と貸方合計の一致(貸借一致)を確認できる。
    """
    return call_api("get_trial_balance", "GET", "/trial-balance")


if __name__ == "__main__":
    mcp.run()  # デフォルトはstdioトランスポート

descriptionは日本語で、使える科目の一覧まで書き込んだ。エージェントはこの文章を実行時に読んでツールの使い方を決めるので、宣言の書き込みの質が、そのままエージェントの動作精度になる。freee-mcpのツール宣言が素っ気ない固定文字列だったのは、仕様知識をAgent Skills側へ分離したからで、うちのように3本しかなければ宣言に直接書くのが早い。同じ問題への、規模に応じた別の解だ。

annotationsには2つ論点を仕込んだ。1つは register_journal_entrydestructiveHint: false。freee-mcpはHTTPメソッドから機械的に導出するので、POSTは一律 destructiveHint: true になる。既存データを知らずに壊す可能性を広めに申告する保守的な設計だ。一方、仕訳の新規登録は既存の仕訳を1件も書き換えない加算的な操作なので、手でツールを書くなら意味に即して false と申告できる。もう1つは、そもそもannotationsは認可を強制しない「クライアント向けのヒント」にすぎないことで、これは→ MCP仕様のツールの節に明記されている。ヒントを見て確認UIを出すかどうかは、クライアントの仕事。この分業は後の実験で実際に確かめる。

Step 4: 宣言を覗いてから、つなぐ

Claude Codeにつなぐ前に、エージェントが実際に何を見るのかを自分の目で確かめたい。MCPクライアントを名乗る50行ほどのスクリプトを書いて、tools/list(ツール宣言の一覧)と tools/call(ツールの実行)を1回ずつ叩く。

"""MCPクライアントとしてserver.pyを起動し、tools/listとtools/callを1回ずつ実行する。

エージェントが実際に見ているツール宣言(生JSON)を自分の目で確かめるための採取スクリプト。
実行: uv run inspect_tools.py(事前に環境変数 JOURNAL_API_KEY を設定し、APIを起動しておく)
"""

import asyncio
import json
import os
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import get_default_environment, stdio_client


async def main() -> None:
    key = os.environ.get("JOURNAL_API_KEY")
    if not key:
        sys.exit("環境変数 JOURNAL_API_KEY を設定してから実行してください。")

    # server.py をサブプロセスとして起動する。環境変数はこの env 経由で明示的に渡す
    server = StdioServerParameters(
        command=sys.executable,
        args=["server.py"],
        env={
            **get_default_environment(),
            "JOURNAL_API_KEY": key,
            "JOURNAL_API_BASE_URL": os.environ.get(
                "JOURNAL_API_BASE_URL", "http://127.0.0.1:8000"
            ),
        },
    )
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            tools = await session.list_tools()
            print("=== tools/list ===")
            print(
                json.dumps(
                    [t.model_dump(exclude_none=True) for t in tools.tools],
                    ensure_ascii=False,
                    indent=2,
                )
            )

            result = await session.call_tool("get_trial_balance", {})
            print("=== tools/call get_trial_balance ===")
            print(json.dumps(result.model_dump(exclude_none=True), ensure_ascii=False, indent=2))


if __name__ == "__main__":
    asyncio.run(main())

APIを起動したまま、別のターミナルで実行する(Step 1のcurlで登録した仕訳が残っていると試算表の数値が変わる。シード状態の出力を再現したいときは、uvicornをいったん再起動してから実行する)。

export JOURNAL_API_KEY="sk-journal-tutorial-2026"
uv run inspect_tools.py

出てきた tools/list の生JSONがこれだ。エージェントが接続時に受け取るものと同じで、名前と説明文と入出力スキーマとannotations、それがすべてだ。server.py の実装コードも、APIキーも、この中には存在しない。

[
  {
    "name": "list_journal_entries",
    "description": "仕訳の一覧を取得する。\n\n    month に \"YYYY-MM\"(例: \"2026-07\")を渡すと、その月の仕訳だけに絞り込む。\n    省略するとすべての仕訳を返す。各仕訳は id・date・debit_account(借方科目)・\n    credit_account(貸方科目)・amount(金額・円)・description(摘要)を持つ。\n    ",
    "inputSchema": {
      "properties": {
        "month": {
          "anyOf": [{ "type": "string" }, { "type": "null" }],
          "default": null,
          "title": "Month"
        }
      },
      "title": "list_journal_entriesArguments",
      "type": "object"
    },
    "outputSchema": {
      "properties": {
        "result": {
          "items": { "additionalProperties": true, "type": "object" },
          "title": "Result",
          "type": "array"
        }
      },
      "required": ["result"],
      "title": "list_journal_entriesOutput",
      "type": "object"
    },
    "annotations": { "readOnlyHint": true }
  },
  {
    "name": "register_journal_entry",
    "description": "仕訳を1件登録する(1行仕訳のみ。借方1科目・貸方1科目)。\n\n    date は \"YYYY-MM-DD\" 形式、amount は正の整数(円)。\n    勘定科目はホワイトリスト制で、使えるのは次の7科目だけ:\n    現金、普通預金、買掛金、資本金、売上高、仕入高、消耗品費。\n    この一覧にない科目(例: 雑費)を渡すと入力エラーになる。\n    ",
    "inputSchema": {
      "properties": {
        "date": { "title": "Date", "type": "string" },
        "debit_account": { "title": "Debit Account", "type": "string" },
        "credit_account": { "title": "Credit Account", "type": "string" },
        "amount": { "title": "Amount", "type": "integer" },
        "description": { "default": "", "title": "Description", "type": "string" }
      },
      "required": ["date", "debit_account", "credit_account", "amount"],
      "title": "register_journal_entryArguments",
      "type": "object"
    },
    "annotations": { "readOnlyHint": false, "destructiveHint": false }
  },
  {
    "name": "get_trial_balance",
    "description": "勘定科目別の残高試算表を取得する。\n\n    科目ごとに借方合計(debit_total)・貸方合計(credit_total)・残高(balance)・\n    残高の側(balance_side)・区分から決まる正常残高側(normal_side)を返す。\n    両者が食い違う科目には is_abnormal: true が立つ(例: 現金なのに貸方残)。\n    末尾の total で借方合計と貸方合計の一致(貸借一致)を確認できる。\n    ",
    "inputSchema": {
      "properties": {},
      "title": "get_trial_balanceArguments",
      "type": "object"
    },
    "annotations": { "readOnlyHint": true }
  }
]

register_journal_entry の引数を見てほしい。date、借方科目、貸方科目、金額、摘要。キーというフィールドが存在しない。エージェントが何をどう間違えても、ツール呼び出しの引数にキーを書く場所自体がない。Before体験で「エージェントの毎回の振る舞い」に頼っていた秘密の管理が、宣言の形そのものに置き換わっている。

続けて tools/call の結果(シード状態の残高試算表)も返ってきた。

{
  "content": [
    {
      "type": "text",
      "text": "{\n  \"accounts\": [\n    {\n      \"account\": \"現金\",\n      \"category\": \"資産\",\n      \"debit_total\": 180000,\n      \"credit_total\": 85000,\n      \"balance_side\": \"借方\",\n      \"balance\": 95000,\n      \"normal_side\": \"借方\",\n      \"is_abnormal\": false\n    },\n    (中略: 期待値表と同じ6科目、total は 265000 / 265000 / in_balance: true)\n  ]\n}"
    }
  ],
  "isError": false
}

Claude Codeにつなぎ、同じ依頼文をもう一度投げる(After体験)

接続はプロジェクト直下に .mcp.json を置くだけだ。キーの値はファイルに書かず、${JOURNAL_API_KEY} で環境変数から展開させる。

{
  "mcpServers": {
    "journal": {
      "command": "uv",
      "args": ["run", "server.py"],
      "env": {
        "JOURNAL_API_KEY": "${JOURNAL_API_KEY}",
        "JOURNAL_API_BASE_URL": "http://127.0.0.1:8000"
      }
    }
  }
}

2つ注意がある。対話モードのClaude Codeでこのプロジェクトを開くと、初回に「.mcp.jsonのサーバーを信頼するか」の確認が出るので承認する(接続状態は /mcp コマンドで確認できる)。そして ${JOURNAL_API_KEY} はclaudeを起動したシェルの環境変数から展開されるので、export とclaudeの起動は必ず同じターミナルで行う。

APIをシード状態で再起動し、Before体験と同じ依頼文を投げる。実測に使ったコマンドはこれだ(前置きは「ミニ仕訳帳のMCPツール(journal)が接続されている。仕訳の操作はそのツールを使って。」の1行だけ。URLも認証方式も伝えていない)。

export JOURNAL_API_KEY="sk-journal-tutorial-2026"
claude -p '(前置き+依頼文)' --mcp-config .mcp.json --strict-mcp-config \
  --allowedTools "mcp__journal__list_journal_entries,mcp__journal__get_trial_balance,mcp__journal__register_journal_entry" \
  --output-format stream-json --verbose

正常系の実録。

[1] ToolSearch query="journal 仕訳 登録"
    → journalサーバーのツール宣言をロード
[2] mcp__journal__register_journal_entry
    {"date":"2026-07-25","debit_account":"消耗品費","credit_account":"現金",
     "amount":3300,"description":"消耗品費の現金支払い"}
    → {"date":"2026-07-25",...,"id":6}

一発で成功した。Before体験で6ターンだったものが3ターン。openapi.jsonの読解ステップが消え(仕様は宣言に書いてある)、Windowsのエンコーディング事故も消えた。事故が消えたのは偶然ではない。引数がシェルのコマンドライン文字列ではなく、MCPの構造化JSONとして流れるからで、「文字列の組み立て」という事故の発生場所そのものが経路からなくなっている。

journal_mcp.log には操作ログが1行残った。

{"time": "2026-07-14T11:07:02", "tool": "register_journal_entry", "args": {"body": {"date": "2026-07-25", "debit_account": "消耗品費", "credit_account": "現金", "amount": 3300, "description": "消耗品費の現金支払い"}}, "status": "201"}

ちなみにAPI側(uvicorn)のアクセスログは "POST /journal-entries HTTP/1.1" 201 Created という1行で、何をどんな引数で登録したかは残らない。ツール名と引数と結果が揃うのはMCPサーバーの層だけだ。だからこそ、この層のログ設計に価値があるし、freee-mcpがログに「何を残さないか」(ツール引数の中身、つまり会計データ)まで設計していたことの意味もわかる。うちの教材ログは引数を丸ごと残しているが、運用ログに顧客の会計データを書いてよいかは、実務なら立ち止まるべき論点だ。

エラー系(雑費)の実録。

[1] ToolSearch query="+journal 仕訳 登録"
[2] mcp__journal__register_journal_entry {"debit_account":"雑費",...}
    → isError: true
      "Error executing tool register_journal_entry: 入力エラーで受け付けられませんでした。
       - debit_account: Value error, 勘定科目「雑費」は使えません。使える科目: 現金、普通預金、
         買掛金、資本金、売上高、仕入高、消耗品費
       使える勘定科目: 現金、普通預金、買掛金、資本金、売上高、仕入高、消耗品費。
       日付は YYYY-MM-DD、金額は正の整数(円)です。"
[3] 登録せず停止。「消耗品費に変えて登録する/server.pyのホワイトリストに雑費を
    追加する」の2択をユーザーに提示

最終判断はBeforeと同じで、勝手に読み替えず人間に確認を求めた。違うのは途中経過だ。Beforeでは原因調査のためにソースコードをgrepし、その巻き添えでキーが会話に出た。Afterでは、整形エラー文が「何がだめで、何なら使えるか」を1回で伝えたので、ソースを漁る動機自体が発生していない。エラー文面の中に科目一覧が2回出ているのはご愛嬌で、FastAPIのバリデータ由来の文と、format_422 が付け足した文が重なった結果だ(金額や日付のエラーでは付け足しの方だけが情報源になるので、重複を承知で残している)。

もう1つ、承認の粒度を確かめる実験をした。許可リストを読み取り系2ツールだけにして、--permission-mode default で権限確認を有効にした状態で、同じ正常系の依頼を投げる。実測に使ったコマンドはこれだ。

claude -p '(前置き+依頼文)' --mcp-config .mcp.json --strict-mcp-config \
  --permission-mode default \
  --allowedTools "mcp__journal__list_journal_entries,mcp__journal__get_trial_balance" \
  --output-format stream-json --verbose
[2] mcp__journal__register_journal_entry {...}
    → 拒否: "Claude requested permissions to use mcp__journal__register_journal_entry,
       but you haven't granted it yet."
[3] エージェントは登録内容(日付・借方・貸方・金額・摘要)を提示して承認待ちで停止

仕訳は登録されず、シードの5件のままだった。ヘッドレス実行では承認に答える手段がないのでここで止まるが、対話モードなら承認ダイアログが出て、許可すれば登録まで進む。読み取りは素通しにして、書き込みだけ止める。この制御がツール名の単位で効いている。Before体験ではこうはいかない。エージェントに許可するのは「Bash(シェルコマンドの実行)」という単位で、読み取りのcurlも書き込みのcurlも同じコマンド実行として見える。HTTPメソッドの意味を許可ルールの層で見分ける標準の経路がないからだ。OpenAPI仕様にはGETとPOSTの区別がちゃんとある。ないのは、その区別をクライアントの確認UIまで運ぶ配線で、MCPのツール分割とannotationsはその配線として機能する。

ただし、正直に書いておくと、この実験は1回目に失敗している。私のClaude Codeは普段、権限確認を自動許可するモードで動いていて、その設定のままでは、許可リストに入れていない書き込みツールもそのまま通った。annotationsが「強制力のないヒント」であり、実際に止めるかどうかはクライアントの設定次第、という分業の実演を、意図せず自分でやってしまった格好だ。サーバーが正直に申告していても、クライアント側の統制がゆるければ素通りする。導入するときは、どちらの層で何を止めているかを両方確認した方がいい。

Before/Afterの差分表

測れた差だけを表にする。

観測点Before(生API直叩き)After(MCP経由)
仕様の伝達前置きでURLと認証方式を説明し、エージェントがopenapi.jsonを毎回読解前置き1行。仕様はツール宣言に載って自動で届く
秘密の扱いエージェントはキーをechoしなかったが、エラー原因調査のgrepの巻き添えでキー値が会話に出現キー値は一度も出現せず。ツール引数にキーのフィールド自体が存在しない
承認の粒度「Bash実行」の単位。読み取りと書き込みのcurlを許可ルールで区別する標準経路がないツール単位。読み取り2本を許可し書き込みだけ拒否、を実測で確認
エラー復旧422の後、ソースコードをgrepして原因特定してから停止(2手余分)整形エラー文だけで即停止、確認要求。ソース調査なし
操作ログuvicornのアクセスログのみ(メソッドとパスとステータス)ツール名と引数と結果が1呼び出し1行で残る
Windows固有の事故日本語JSONのシェル渡しで400が2回とも再現構造化JSONで渡るため発生せず
ターン数(参考)正常系6、エラー系7どちらも3

差が出なかった項目も書いておく。最終的なガバナンス挙動は同じだった。雑費のエラーに対して、どちらの経路でもエージェントは科目を勝手に読み替えず、選択肢を提示して停止した。モデルも依頼文も同じなのだから、判断が変わる理由はない。変わったのは、その判断に至るまでに事故が起きる場所と手数だ。MCPは、エージェントを賢くする規格ではなく、事故が起きる場所を経路ごと減らす規格だった。この表がその一覧になっている。

もしエンドポイントが277本あったら

ここまで意図的に避けてきたコストの話を、最後にする。

tools/list が返した3ツール分の宣言は、圧縮したJSONで2,429バイト、1ツールあたり平均810バイトだった。これはMCPのプロトコル上でクライアントに渡る宣言のサイズであり、freeeの規模(一意パス277本)を1対1のツールにしたと仮定して外挿すると約219KB、メソッド単位の416操作なら約329KBになる。外挿であって実測ではないことは明記しておく。それでも、宣言だけで数百KBという量が「接続しただけで発生する荷物」になり得ることは、この計算で十分に見える。

ただし、この宣言サイズがそのままLLMのコンテキスト消費になるわけではない。宣言をいつ、どれだけモデルに渡すかはMCPクライアント(ホスト)の実装に任されている。実際、今回のAfter体験の実録には面白い痕跡が残っていた。セッション開始時点でjournalサーバーは「pending」で、エージェントは依頼を受けてから検索ツールで宣言をロードしている。Claude CodeはMCPツールの宣言を遅延ロードする実装になっていて、接続した瞬間に全宣言をコンテキストへ流し込んではいない(この見え方はClaude Codeのバージョンや設定に依存する。実録にToolSearchが現れず、最初からツールが見えていてもそれは正常だ)。

つまり、宣言の肥大化には2つの対処層がある。ホスト側の遅延ロード(Claude Codeがやっていること)と、サーバー側の設計(freee-mcpが汎用5ツール+Agent Skillsへ畳み込んだこと)だ。3本の教材では1対1ツールが素直で、277本のfreeeでは畳み込みが要る。前の記事で見た「API呼び出しは汎用5ツール、全体でも15個」という設計は、この外挿計算の先にある必然だった、というのが手を動かした後の実感になる。

締め: アプリの次は、エージェント同士がつながる

作ったものは、APIが1本、それを包むMCPサーバーが1本、ツール宣言が3つ。この最小の部品で、アプリケーションとエージェントの間の規格が固まると何が起きるかを確かめた。

そして、アプリとエージェントの接続が規格で埋まった今、次に規格化が進んでいるのはエージェントとエージェントの間だ。→ A2A(Agent2Agent Protocol・GitHub)のように、エージェント同士が能力を宣言し合い、タスクを依頼し合うプロトコルが既に動き始めている。今回の教材で言えば、仕訳を登録する側のエージェントと、試算表を検証する側のエージェントが、人間を介さずに突き合わせをする形だ。

会計事務所の文脈に置き直すと、こうなる。あなたの事務所のエージェントが、顧問先のエージェントに「7月分の売上仕訳を根拠資料付きで」と依頼し、受け取った仕訳を試算表と突き合わせ、異常残高だけを人間の机に上げる。今日それができると言いたいのではない。ただ、その世界の部品が何でできているかは、もう見えている。APIがあり、それを包む宣言があり、許可の単位があり、操作の記録がある。今回手元で作った3つのツールは、その世界の最小単位だった。

freee-mcpを読んで、模型を作って、実測した。次に「エージェント対応」と書かれたサービスを見たとき、その裏で何が宣言され、何が隠され、どこに記録が残るのか。それを具体的に想像できるようになったことが、このハンズオンの収穫だ。

まとめ

  • ミニ仕訳帳API(FastAPIで3エンドポイント、約150行)を自作し、MCP公式SDKのFastMCPで包んだ(約170行)。コードは全文掲載、出力はすべて実測値
  • 生のAPIをエージェントに使わせると、仕様の読解が毎回走り、Windowsのシェル経由で日本語JSONが壊れる事故を踏み、422の原因調査でソースを漁った巻き添えにAPIキーが会話へ流れ込んだ
  • MCP経由では同じ依頼が3ターンで完了。キーはツールの宣言にも引数にも存在せず、エンコーディング事故は経路ごと消え、整形エラーがソース調査の動機をなくした
  • 承認はツール単位で制御でき、読み取りだけ許可して書き込みを止める動作を実測した。ただしannotationsは強制力のないヒントで、クライアント側が自動許可モードなら素通りする(実測済み)
  • エージェントの判断は両経路で同じだった。雑費のエラーに対し、どちらも科目を読み替えず人間に確認を求めた。変わったのは、そこへ至るまでに事故が起きる場所と手数
  • ツール宣言の肥大化には、ホスト側の遅延ロードとサーバー側のツール畳み込み(freee-mcpの汎用5ツール+Agent Skills)という2層の対処がある。宣言サイズの実測と277本への外挿は「もしエンドポイントが277本あったら」の節に書いた
  • 操作ログ(JSONLで1呼び出し1行)は監査証跡ではない。実行者の識別、改ざん検知、API側記録との突合がない記録は、証跡と呼ぶには距離がある
  • アプリとエージェントの接続(MCP)の次は、エージェント同士の通信(A2A)の規格化が進んでいる。今回の3ツールは、エージェント同士が帳簿を突き合わせる世界の最小単位になる

参考リンク